diff options
Diffstat (limited to 'activerecord')
270 files changed, 6446 insertions, 2994 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 618aecfd5d..a787e6dca7 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,625 +1,5 @@ * Allow generated create_table migrations to include or skip timestamps - *Michael Duchemin* + *Michael Duchemin* -* Fix `relation.create` to avoid leaking scope to initialization block and callbacks. - - Fixes #9894, #17577. - - *Ryuta Kamizono* - -* Chaining named scope is no longer leaking to class level querying methods. - - Fixes #14003. - - *Ryuta Kamizono* - -* Allow applications to automatically switch connections. - - Adds a middleware and configuration options that can be used in your - application to automatically switch between the writing and reading - database connections. - - `GET` and `HEAD` requests will read from the replica unless there was - a write in the last 2 seconds, otherwise they will read from the primary. - Non-get requests will always write to the primary. The middleware accepts - an argument for a Resolver class and a Operations class where you are able - to change how the auto-switcher works to be most beneficial for your - application. - - To use the middleware in your application you can use the following - configuration options: - - ``` - config.active_record.database_selector = { delay: 2.seconds } - config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver - config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session - ``` - - To change the database selection strategy, pass a custom class to the - configuration options: - - ``` - config.active_record.database_selector = { delay: 10.seconds } - config.active_record.database_resolver = MyResolver - config.active_record.database_resolver_context = MyResolver::MyCookies - ``` - - *Eileen M. Uchitelle* - -* MySQL: Support `:size` option to change text and blob size. - - *Ryuta Kamizono* - -* Make `t.timestamps` with precision by default. - - *Ryuta Kamizono* - - -## Rails 6.0.0.beta1 (January 18, 2019) ## - -* Remove deprecated `#set_state` from the transaction object. - - *Rafael Mendonça França* - -* Remove deprecated `#supports_statement_cache?` from the database adapters. - - *Rafael Mendonça França* - -* Remove deprecated `#insert_fixtures` from the database adapters. - - *Rafael Mendonça França* - -* Remove deprecated `ActiveRecord::ConnectionAdapters::SQLite3Adapter#valid_alter_table_type?`. - - *Rafael Mendonça França* - -* Do not allow passing the column name to `sum` when a block is passed. - - *Rafael Mendonça França* - -* Do not allow passing the column name to `count` when a block is passed. - - *Rafael Mendonça França* - -* Remove delegation of missing methods in a relation to arel. - - *Rafael Mendonça França* - -* Remove delegation of missing methods in a relation to private methods of the class. - - *Rafael Mendonça França* - -* Deprecate `config.activerecord.sqlite3.represent_boolean_as_integer`. - - *Rafael Mendonça França* - -* Change `SQLite3Adapter` to always represent boolean values as integers. - - *Rafael Mendonça França* - -* Remove ability to specify a timestamp name for `#cache_key`. - - *Rafael Mendonça França* - -* Remove deprecated `ActiveRecord::Migrator.migrations_path=`. - - *Rafael Mendonça França* - -* Remove deprecated `expand_hash_conditions_for_aggregates`. - - *Rafael Mendonça França* - -* Set polymorphic type column to NULL on `dependent: :nullify` strategy. - - On polymorphic associations both the foreign key and the foreign type columns will be set to NULL. - - *Laerti Papa* - -* Allow permitted instance of `ActionController::Parameters` as argument of `ActiveRecord::Relation#exists?`. - - *Gannon McGibbon* - -* Add support for endless ranges introduces in Ruby 2.6. - - *Greg Navis* - -* Deprecate passing `migrations_paths` to `connection.assume_migrated_upto_version`. - - *Ryuta Kamizono* - -* MySQL: `ROW_FORMAT=DYNAMIC` create table option by default. - - Since MySQL 5.7.9, the `innodb_default_row_format` option defines the default row - format for InnoDB tables. The default setting is `DYNAMIC`. - The row format is required for indexing on `varchar(255)` with `utf8mb4` columns. - - *Ryuta Kamizono* - -* Fix join table column quoting with SQLite. - - *Gannon McGibbon* - -* Allow disabling scopes generated by `ActiveRecord.enum`. - - *Alfred Dominic* - -* Ensure that `delete_all` on collection proxy returns affected count. - - *Ryuta Kamizono* - -* Reset scope after delete on collection association to clear stale offsets of removed records. - - *Gannon McGibbon* - -* Add the ability to prevent writes to a database for the duration of a block. - - Allows the application to prevent writes to a database. This can be useful when - you're building out multiple databases and want to make sure you're not sending - writes when you want a read. - - If `while_preventing_writes` is called and the query is considered a write - query the database will raise an exception regardless of whether the database - user is able to write. - - This is not meant to be a catch-all for write queries but rather a way to enforce - read-only queries without opening a second connection. One purpose of this is to - catch accidental writes, not all writes. - - *Eileen M. Uchitelle* - -* Allow aliased attributes to be used in `#update_columns` and `#update`. - - *Gannon McGibbon* - -* Allow spaces in postgres table names. - - Fixes issue where "user post" is misinterpreted as "\"user\".\"post\"" when quoting table names with the postgres adapter. - - *Gannon McGibbon* - -* Cached columns_hash fields should be excluded from ResultSet#column_types - - PR #34528 addresses the inconsistent behaviour when attribute is defined for an ignored column. The following test - was passing for SQLite and MySQL, but failed for PostgreSQL: - - ```ruby - class DeveloperName < ActiveRecord::Type::String - def deserialize(value) - "Developer: #{value}" - end - end - - class AttributedDeveloper < ActiveRecord::Base - self.table_name = "developers" - - attribute :name, DeveloperName.new - - self.ignored_columns += ["name"] - end - - developer = AttributedDeveloper.create - developer.update_column :name, "name" - - loaded_developer = AttributedDeveloper.where(id: developer.id).select("*").first - puts loaded_developer.name # should be "Developer: name" but it's just "name" - ``` - - *Dmitry Tsepelev* - -* Make the implicit order column configurable. - - When calling ordered finder methods such as +first+ or +last+ without an - explicit order clause, ActiveRecord sorts records by primary key. This can - result in unpredictable and surprising behaviour when the primary key is - not an auto-incrementing integer, for example when it's a UUID. This change - makes it possible to override the column used for implicit ordering such - that +first+ and +last+ will return more predictable results. - - Example: - - class Project < ActiveRecord::Base - self.implicit_order_column = "created_at" - end - - *Tekin Suleyman* - -* Bump minimum PostgreSQL version to 9.3. - - *Yasuo Honda* - -* Values of enum are frozen, raising an error when attempting to modify them. - - *Emmanuel Byrd* - -* Move `ActiveRecord::StatementInvalid` SQL to error property and include binds as separate error property. - - `ActiveRecord::ConnectionAdapters::AbstractAdapter#translate_exception_class` now requires `binds` to be passed as the last argument. - - `ActiveRecord::ConnectionAdapters::AbstractAdapter#translate_exception` now requires `message`, `sql`, and `binds` to be passed as keyword arguments. - - Subclasses of `ActiveRecord::StatementInvalid` must now provide `sql:` and `binds:` arguments to `super`. - - Example: - - ``` - class MySubclassedError < ActiveRecord::StatementInvalid - def initialize(message, sql:, binds:) - super(message, sql: sql, binds: binds) - end - end - ``` - - *Gannon McGibbon* - -* Add an `:if_not_exists` option to `create_table`. - - Example: - - create_table :posts, if_not_exists: true do |t| - t.string :title - end - - That would execute: - - CREATE TABLE IF NOT EXISTS posts ( - ... - ) - - If the table already exists, `if_not_exists: false` (the default) raises an - exception whereas `if_not_exists: true` does nothing. - - *fatkodima*, *Stefan Kanev* - -* Defining an Enum as a Hash with blank key, or as an Array with a blank value, now raises an `ArgumentError`. - - *Christophe Maximin* - -* Adds support for multiple databases to `rails db:schema:cache:dump` and `rails db:schema:cache:clear`. - - *Gannon McGibbon* - -* `update_columns` now correctly raises `ActiveModel::MissingAttributeError` - if the attribute does not exist. - - *Sean Griffin* - -* Add support for hash and url configs in database hash of `ActiveRecord::Base.connected_to`. - - ```` - User.connected_to(database: { writing: "postgres://foo" }) do - User.create!(name: "Gannon") - end - - config = { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } - User.connected_to(database: { reading: config }) do - User.count - end - ```` - - *Gannon McGibbon* - -* Support default expression for MySQL. - - MySQL 8.0.13 and higher supports default value to be a function or expression. - - https://dev.mysql.com/doc/refman/8.0/en/create-table.html - - *Ryuta Kamizono* - -* Support expression indexes for MySQL. - - MySQL 8.0.13 and higher supports functional key parts that index - expression values rather than column or column prefix values. - - https://dev.mysql.com/doc/refman/8.0/en/create-index.html - - *Ryuta Kamizono* - -* Fix collection cache key with limit and custom select to avoid ambiguous timestamp column error. - - Fixes #33056. - - *Federico Martinez* - -* Add basic API for connection switching to support multiple databases. - - 1) Adds a `connects_to` method for models to connect to multiple databases. Example: - - ``` - class AnimalsModel < ApplicationRecord - self.abstract_class = true - - connects_to database: { writing: :animals_primary, reading: :animals_replica } - end - - class Dog < AnimalsModel - # connected to both the animals_primary db for writing and the animals_replica for reading - end - ``` - - 2) Adds a `connected_to` block method for switching connection roles or connecting to - a database that the model didn't connect to. Connecting to the database in this block is - useful when you have another defined connection, for example `slow_replica` that you don't - want to connect to by default but need in the console, or a specific code block. - - ``` - ActiveRecord::Base.connected_to(role: :reading) do - Dog.first # finds dog from replica connected to AnimalsBase - Book.first # doesn't have a reading connection, will raise an error - end - ``` - - ``` - ActiveRecord::Base.connected_to(database: :slow_replica) do - SlowReplicaModel.first # if the db config has a slow_replica configuration this will be used to do the lookup, otherwise this will throw an exception - end - ``` - - *Eileen M. Uchitelle* - -* Enum raises on invalid definition values - - When defining a Hash enum it can be easy to use [] instead of {}. This - commit checks that only valid definition values are provided, those can - be a Hash, an array of Symbols or an array of Strings. Otherwise it - raises an ArgumentError. - - Fixes #33961 - - *Alberto Almagro* - -* Reloading associations now clears the Query Cache like `Persistence#reload` does. - - ``` - class Post < ActiveRecord::Base - has_one :category - belongs_to :author - has_many :comments - end - - # Each of the following will now clear the query cache. - post.reload_category - post.reload_author - post.comments.reload - ``` - - *Christophe Maximin* - -* Added `index` option for `change_table` migration helpers. - With this change you can create indexes while adding new - columns into the existing tables. - - Example: - - change_table(:languages) do |t| - t.string :country_code, index: true - end - - *Mehmet Emin İNAÇ* - -* Fix `transaction` reverting for migrations. - - Before: Commands inside a `transaction` in a reverted migration ran uninverted. - Now: This change fixes that by reverting commands inside `transaction` block. - - *fatkodima*, *David Verhasselt* - -* Raise an error instead of scanning the filesystem root when `fixture_path` is blank. - - *Gannon McGibbon*, *Max Albrecht* - -* Allow `ActiveRecord::Base.configurations=` to be set with a symbolized hash. - - *Gannon McGibbon* - -* Don't update counter cache unless the record is actually saved. - - Fixes #31493, #33113, #33117. - - *Ryuta Kamizono* - -* Deprecate `ActiveRecord::Result#to_hash` in favor of `ActiveRecord::Result#to_a`. - - *Gannon McGibbon*, *Kevin Cheng* - -* SQLite3 adapter supports expression indexes. - - ``` - create_table :users do |t| - t.string :email - end - - add_index :users, 'lower(email)', name: 'index_users_on_email', unique: true - ``` - - *Gray Kemmey* - -* Allow subclasses to redefine autosave callbacks for associated records. - - Fixes #33305. - - *Andrey Subbota* - -* Bump minimum MySQL version to 5.5.8. - - *Yasuo Honda* - -* Use MySQL utf8mb4 character set by default. - - `utf8mb4` character set with 4-Byte encoding supports supplementary characters including emoji. - The previous default 3-Byte encoding character set `utf8` is not enough to support them. - - *Yasuo Honda* - -* Fix duplicated record creation when using nested attributes with `create_with`. - - *Darwin Wu* - -* Configuration item `config.filter_parameters` could also filter out - sensitive values of database columns when call `#inspect`. - We also added `ActiveRecord::Base::filter_attributes`/`=` in order to - specify sensitive attributes to specific model. - - ``` - Rails.application.config.filter_parameters += [:credit_card_number, /phone/] - Account.last.inspect # => #<Account id: 123, name: "DHH", credit_card_number: [FILTERED], telephone_number: [FILTERED] ...> - SecureAccount.filter_attributes += [:name] - SecureAccount.last.inspect # => #<SecureAccount id: 42, name: [FILTERED], credit_card_number: [FILTERED] ...> - ``` - - *Zhang Kang*, *Yoshiyuki Kinjo* - -* Deprecate `column_name_length`, `table_name_length`, `columns_per_table`, - `indexes_per_table`, `columns_per_multicolumn_index`, `sql_query_length`, - and `joins_per_query` methods in `DatabaseLimits`. - - *Ryuta Kamizono* - -* `ActiveRecord::Base.configurations` now returns an object. - - `ActiveRecord::Base.configurations` used to return a hash, but this - is an inflexible data model. In order to improve multiple-database - handling in Rails, we've changed this to return an object. Some methods - are provided to make the object behave hash-like in order to ease the - transition process. Since most applications don't manipulate the hash - we've decided to add backwards-compatible functionality that will throw - a deprecation warning if used, however calling `ActiveRecord::Base.configurations` - will use the new version internally and externally. - - For example, the following `database.yml`: - - ``` - development: - adapter: sqlite3 - database: db/development.sqlite3 - ``` - - Used to become a hash: - - ``` - { "development" => { "adapter" => "sqlite3", "database" => "db/development.sqlite3" } } - ``` - - Is now converted into the following object: - - ``` - #<ActiveRecord::DatabaseConfigurations:0x00007fd1acbdf800 @configurations=[ - #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 @env_name="development", - @spec_name="primary", @config={"adapter"=>"sqlite3", "database"=>"db/development.sqlite3"}> - ] - ``` - - Iterating over the database configurations has also changed. Instead of - calling hash methods on the `configurations` hash directly, a new method `configs_for` has - been provided that allows you to select the correct configuration. `env_name`, and - `spec_name` arguments are optional. For example these return an array of - database config objects for the requested environment and a single database config object - will be returned for the requested environment and specification name respectively. - - ``` - ActiveRecord::Base.configurations.configs_for(env_name: "development") - ActiveRecord::Base.configurations.configs_for(env_name: "development", spec_name: "primary") - ``` - - *Eileen M. Uchitelle*, *Aaron Patterson* - -* Add database configuration to disable advisory locks. - - ``` - production: - adapter: postgresql - advisory_locks: false - ``` - - *Guo Xiang* - -* SQLite3 adapter `alter_table` method restores foreign keys. - - *Yasuo Honda* - -* Allow `:to_table` option to `invert_remove_foreign_key`. - - Example: - - remove_foreign_key :accounts, to_table: :owners - - *Nikolay Epifanov*, *Rich Chen* - -* Add environment & load_config dependency to `bin/rake db:seed` to enable - seed load in environments without Rails and custom DB configuration - - *Tobias Bielohlawek* - -* Fix default value for mysql time types with specified precision. - - *Nikolay Kondratyev* - -* Fix `touch` option to behave consistently with `Persistence#touch` method. - - *Ryuta Kamizono* - -* Migrations raise when duplicate column definition. - - Fixes #33024. - - *Federico Martinez* - -* Bump minimum SQLite version to 3.8 - - *Yasuo Honda* - -* Fix parent record should not get saved with duplicate children records. - - Fixes #32940. - - *Santosh Wadghule* - -* Fix logic on disabling commit callbacks so they are not called unexpectedly when errors occur. - - *Brian Durand* - -* Ensure `Associations::CollectionAssociation#size` and `Associations::CollectionAssociation#empty?` - use loaded association ids if present. - - *Graham Turner* - -* Add support to preload associations of polymorphic associations when not all the records have the requested associations. - - *Dana Sherson* - -* Add `touch_all` method to `ActiveRecord::Relation`. - - Example: - - Person.where(name: "David").touch_all(time: Time.new(2020, 5, 16, 0, 0, 0)) - - *fatkodima*, *duggiefresh* - -* Add `ActiveRecord::Base.base_class?` predicate. - - *Bogdan Gusiev* - -* Add custom prefix/suffix options to `ActiveRecord::Store.store_accessor`. - - *Tan Huynh*, *Yukio Mizuta* - -* Rails 6 requires Ruby 2.5.0 or newer. - - *Jeremy Daer*, *Kasper Timm Hansen* - -* Deprecate `update_attributes`/`!` in favor of `update`/`!`. - - *Eddie Lebow* - -* Add `ActiveRecord::Base.create_or_find_by`/`!` to deal with the SELECT/INSERT race condition in - `ActiveRecord::Base.find_or_create_by`/`!` by leaning on unique constraints in the database. - - *DHH* - -* Add `Relation#pick` as short-hand for single-value plucks. - - *DHH* - - -Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activerecord/CHANGELOG.md) for previous changes. +Please check [6-0-stable](https://github.com/rails/rails/blob/6-0-stable/activerecord/CHANGELOG.md) for previous changes. diff --git a/activerecord/README.rdoc b/activerecord/README.rdoc index 19650b82ae..be573af4ba 100644 --- a/activerecord/README.rdoc +++ b/activerecord/README.rdoc @@ -13,6 +13,8 @@ columns. Although these mappings can be defined explicitly, it's recommended to follow naming conventions, especially when getting started with the library. +You can read more about Active Record in the {Active Record Basics}[https://edgeguides.rubyonrails.org/active_record_basics.html] guide. + A short rundown of some of the major features: * Automated mapping between classes and tables, attributes and columns. @@ -206,7 +208,7 @@ Active Record is released under the MIT license: API documentation is at: -* http://api.rubyonrails.org +* https://api.rubyonrails.org Bug reports for the Ruby on Rails project can be filed here: diff --git a/activerecord/RUNNING_UNIT_TESTS.rdoc b/activerecord/RUNNING_UNIT_TESTS.rdoc index 60561e2c0f..37473c37c6 100644 --- a/activerecord/RUNNING_UNIT_TESTS.rdoc +++ b/activerecord/RUNNING_UNIT_TESTS.rdoc @@ -1,7 +1,7 @@ == Setup If you don't have an environment for running tests, read -http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#setting-up-a-development-environment +https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#setting-up-a-development-environment == Running the Tests diff --git a/activerecord/Rakefile b/activerecord/Rakefile index 9824787658..f259ae7e12 100644 --- a/activerecord/Rakefile +++ b/activerecord/Rakefile @@ -65,11 +65,90 @@ end task adapter => "#{adapter}:env" do adapter_short = adapter == "db2" ? adapter : adapter[/^[a-z0-9]+/] puts [adapter, adapter_short].inspect - (Dir["test/cases/**/*_test.rb"].reject { + + dash_i = [ + "test", + "lib", + "../activesupport/lib", + "../activemodel/lib" + ].map { |dir| File.expand_path(dir, __dir__) } + + dash_i.reverse_each do |x| + $:.unshift(x) unless $:.include?(x) + end + $-w = true + + require "bundler/setup" unless defined?(Bundler) + + # Every test file loads "cases/helper" first, so doing it + # post-fork gains us nothing. + + # We need to dance around minitest autorun, though. + require "minitest" + Minitest.instance_eval do + alias _original_autorun autorun + def autorun + # no-op + end + require "cases/helper" + alias autorun _original_autorun + end + + failing_files = [] + + test_options = ENV["TESTOPTS"].to_s.split(/[\s]+/) + + test_files = (Dir["test/cases/**/*_test.rb"].reject { |x| x.include?("/adapters/") - } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).all? do |file| - sh(Gem.ruby, "-w", "-Itest", file) - end || raise("Failures") + } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).sort + + if ENV["BUILDKITE_PARALLEL_JOB_COUNT"] + n = ENV["BUILDKITE_PARALLEL_JOB"].to_i + m = ENV["BUILDKITE_PARALLEL_JOB_COUNT"].to_i + + test_files = test_files.each_slice(m).map { |slice| slice[n] }.compact + end + + test_files.each do |file| + puts "--- #{file}" + fake_command = Shellwords.join([ + FileUtils::RUBY, + "-w", + *dash_i.map { |dir| "-I#{Pathname.new(dir).relative_path_from(Pathname.pwd)}" }, + file, + ]) + puts fake_command + + # We could run these in parallel, but pretty much all of the + # railties tests already run in parallel, so ¯\_(⊙︿⊙)_/¯ + Process.waitpid fork { + ARGV.clear.concat test_options + Rake.application = nil + + Minitest.autorun + + load file + } + + unless $?.success? + failing_files << file + puts "^^^ +++" + end + puts + end + + puts "--- All tests completed" + unless failing_files.empty? + puts "^^^ +++" + puts + puts "Failed in:" + failing_files.each do |file| + puts " #{file}" + end + puts + + exit 1 + end end end end diff --git a/activerecord/activerecord.gemspec b/activerecord/activerecord.gemspec index 1e198c3a55..f73233c38b 100644 --- a/activerecord/activerecord.gemspec +++ b/activerecord/activerecord.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |s| s.author = "David Heinemeier Hansson" s.email = "david@loudthinking.com" - s.homepage = "http://rubyonrails.org" + s.homepage = "https://rubyonrails.org" s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.rdoc", "examples/**/*", "lib/**/*"] s.require_path = "lib" diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 7d66158f47..fd8d2edf28 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -55,7 +55,6 @@ module ActiveRecord autoload :Persistence autoload :QueryCache autoload :Querying - autoload :CollectionCacheKey autoload :ReadonlyAttributes autoload :RecordInvalid, "active_record/validations" autoload :Reflection diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 7bdbd8ce69..64c20adc87 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -703,8 +703,9 @@ module ActiveRecord # #belongs_to associations. # # Extra options on the associations, as defined in the - # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> constant, will - # also prevent the association's inverse from being found automatically. + # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> + # constant, or a custom scope, will also prevent the association's inverse + # from being found automatically. # # The automatic guessing of the inverse association uses a heuristic based # on the name of the class, so it may not work for all associations, diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index 5d0927f17d..cf22b850b9 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -17,6 +17,23 @@ module ActiveRecord # CollectionAssociation # HasManyAssociation + ForeignAssociation # HasManyThroughAssociation + ThroughAssociation + # + # Associations in Active Record are middlemen between the object that + # holds the association, known as the <tt>owner</tt>, and the associated + # result set, known as the <tt>target</tt>. Association metadata is available in + # <tt>reflection</tt>, which is an instance of <tt>ActiveRecord::Reflection::AssociationReflection</tt>. + # + # For example, given + # + # class Blog < ActiveRecord::Base + # has_many :posts + # end + # + # blog = Blog.first + # + # The association of <tt>blog.posts</tt> has the object +blog+ as its + # <tt>owner</tt>, the collection of its posts as <tt>target</tt>, and + # the <tt>reflection</tt> object represents a <tt>:has_many</tt> macro. class Association #:nodoc: attr_reader :owner, :target, :reflection @@ -208,7 +225,7 @@ module ActiveRecord # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the # through association's scope) def target_scope - AssociationRelation.create(klass, self).merge!(klass.all) + AssociationRelation.create(klass, self).merge!(klass.scope_for_association) end def scope_for_create diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb index 7c69cd65ee..0c61094d6c 100644 --- a/activerecord/lib/active_record/associations/builder/association.rb +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -27,40 +27,32 @@ module ActiveRecord::Associations::Builder # :nodoc: "Please choose a different association name." end - extension = define_extensions model, name, &block - reflection = create_reflection model, name, scope, options, extension + reflection = create_reflection(model, name, scope, options, &block) define_accessors model, reflection define_callbacks model, reflection define_validations model, reflection reflection end - def self.create_reflection(model, name, scope, options, extension = nil) + def self.create_reflection(model, name, scope, options, &block) raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol) validate_options(options) - scope = build_scope(scope, extension) + extension = define_extensions(model, name, &block) + options[:extend] = [*options[:extend], extension] if extension + + scope = build_scope(scope) ActiveRecord::Reflection.create(macro, name, scope, options, model) end - def self.build_scope(scope, extension) - new_scope = scope - + def self.build_scope(scope) if scope && scope.arity == 0 - new_scope = proc { instance_exec(&scope) } - end - - if extension - new_scope = wrap_scope new_scope, extension + proc { instance_exec(&scope) } + else + scope end - - new_scope - end - - def self.wrap_scope(scope, extension) - scope end def self.macro @@ -136,5 +128,9 @@ module ActiveRecord::Associations::Builder # :nodoc: name = reflection.name model.before_destroy lambda { |o| o.association(name).handle_dependency } end + + private_class_method :build_scope, :macro, :valid_options, :validate_options, :define_extensions, + :define_callbacks, :define_accessors, :define_readers, :define_writers, :define_validations, + :valid_dependent_options, :check_dependent_options, :add_destroy_callbacks end end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index fc00f1e900..321ccba918 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -74,11 +74,11 @@ module ActiveRecord::Associations::Builder # :nodoc: def self.add_touch_callbacks(model, reflection) foreign_key = reflection.foreign_key - n = reflection.name + name = reflection.name touch = reflection.options[:touch] callback = lambda { |changes_method| lambda { |record| - BelongsTo.touch_record(record, record.send(changes_method), foreign_key, n, touch, belongs_to_touch_method) + BelongsTo.touch_record(record, record.send(changes_method), foreign_key, name, touch, belongs_to_touch_method) }} if reflection.counter_cache_column @@ -123,5 +123,8 @@ module ActiveRecord::Associations::Builder # :nodoc: model.validates_presence_of reflection.name, message: :required end end + + private_class_method :macro, :valid_options, :valid_dependent_options, :define_callbacks, :define_validations, + :add_counter_cache_callbacks, :add_touch_callbacks, :add_default_callbacks, :add_destroy_callbacks end end diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index ff57c40121..e78d25441b 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -20,11 +20,11 @@ module ActiveRecord::Associations::Builder # :nodoc: } end - def self.define_extensions(model, name) + def self.define_extensions(model, name, &block) if block_given? - extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension" - extension = Module.new(&Proc.new) - model.module_parent.const_set(extension_module_name, extension) + extension_module_name = "#{name.to_s.camelize}AssociationExtension" + extension = Module.new(&block) + model.const_set(extension_module_name, extension) end end @@ -67,16 +67,6 @@ module ActiveRecord::Associations::Builder # :nodoc: CODE end - def self.wrap_scope(scope, mod) - if scope - if scope.arity > 0 - proc { |owner| instance_exec(owner, &scope).extending(mod) } - else - proc { instance_exec(&scope).extending(mod) } - end - else - proc { extending(mod) } - end - end + private_class_method :valid_options, :define_callback, :define_extensions, :define_readers, :define_writers end end diff --git a/activerecord/lib/active_record/associations/builder/has_many.rb b/activerecord/lib/active_record/associations/builder/has_many.rb index 5b9617bc6d..556e2988f5 100644 --- a/activerecord/lib/active_record/associations/builder/has_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_many.rb @@ -13,5 +13,7 @@ module ActiveRecord::Associations::Builder # :nodoc: def self.valid_dependent_options [:destroy, :delete_all, :nullify, :restrict_with_error, :restrict_with_exception] end + + private_class_method :macro, :valid_options, :valid_dependent_options end end diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb index bfb37d6eee..27ebe8cb71 100644 --- a/activerecord/lib/active_record/associations/builder/has_one.rb +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -7,7 +7,7 @@ module ActiveRecord::Associations::Builder # :nodoc: end def self.valid_options(options) - valid = super + [:as] + valid = super + [:as, :touch] valid += [:through, :source, :source_type] if options[:through] valid end @@ -16,6 +16,11 @@ module ActiveRecord::Associations::Builder # :nodoc: [:destroy, :delete, :nullify, :restrict_with_error, :restrict_with_exception] end + def self.define_callbacks(model, reflection) + super + add_touch_callbacks(model, reflection) if reflection.options[:touch] + end + def self.add_destroy_callbacks(model, reflection) super unless reflection.options[:through] end @@ -26,5 +31,34 @@ module ActiveRecord::Associations::Builder # :nodoc: model.validates_presence_of reflection.name, message: :required end end + + def self.touch_record(o, name, touch) + record = o.send name + + return unless record && record.persisted? + + if touch != true + record.touch(touch) + else + record.touch + end + end + + def self.add_touch_callbacks(model, reflection) + name = reflection.name + touch = reflection.options[:touch] + + callback = lambda { |record| + HasOne.touch_record(record, name, touch) + } + + model.after_create callback, if: :saved_changes? + model.after_update callback, if: :saved_changes? + model.after_destroy callback + model.after_touch callback + end + + private_class_method :macro, :valid_options, :valid_dependent_options, :add_destroy_callbacks, + :define_callbacks, :define_validations, :add_touch_callbacks end end diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb index 0a02ef4cc1..0e22563b41 100644 --- a/activerecord/lib/active_record/associations/builder/singular_association.rb +++ b/activerecord/lib/active_record/associations/builder/singular_association.rb @@ -38,5 +38,7 @@ module ActiveRecord::Associations::Builder # :nodoc: end CODE end + + private_class_method :valid_options, :define_accessors, :define_constructors end end diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index b0c0beac0e..c3d4eab562 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -209,8 +209,7 @@ module ActiveRecord # This method is abstract in the sense that it relies on # +count_records+, which is a method descendants have to provide. def size - if !find_target? - loaded! unless loaded? + if !find_target? || loaded? target.size elsif @association_ids @association_ids.size diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index 78993cee8a..85e0f076da 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -2,11 +2,8 @@ module ActiveRecord module Associations - # Association proxies in Active Record are middlemen between the object that - # holds the association, known as the <tt>@owner</tt>, and the actual associated - # object, known as the <tt>@target</tt>. The kind of association any proxy is - # about is available in <tt>@reflection</tt>. That's an instance of the class - # ActiveRecord::Reflection::AssociationReflection. + # Collection proxies in Active Record are middlemen between an + # <tt>association</tt>, and its <tt>target</tt> result set. # # For example, given # @@ -16,14 +13,14 @@ module ActiveRecord # # blog = Blog.first # - # the association proxy in <tt>blog.posts</tt> has the object in +blog+ as - # <tt>@owner</tt>, the collection of its posts as <tt>@target</tt>, and - # the <tt>@reflection</tt> object represents a <tt>:has_many</tt> macro. + # The collection proxy returned by <tt>blog.posts</tt> is built from a + # <tt>:has_many</tt> <tt>association</tt>, and delegates to a collection + # of posts as the <tt>target</tt>. # - # This class delegates unknown methods to <tt>@target</tt> via - # <tt>method_missing</tt>. + # This class delegates unknown methods to the <tt>association</tt>'s + # relation class via a delegate cache. # - # The <tt>@target</tt> object is not \loaded until needed. For example, + # The <tt>target</tt> result set is not loaded until needed. For example, # # blog.posts.count # @@ -1005,7 +1002,7 @@ module ActiveRecord end # Adds one or more +records+ to the collection by setting their foreign keys - # to the association's primary key. Since +<<+ flattens its argument list and + # to the association's primary key. Since <tt><<</tt> flattens its argument list and # inserts each record, +push+ and +concat+ behave identically. Returns +self+ # so several appends may be chained together. # diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 6f67934a79..5972846940 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -52,7 +52,11 @@ module ActiveRecord # If the collection is empty the target is set to an empty array and # the loaded flag is set to true as well. def count_records - count = counter_cache_value || scope.count(:all) + count = if reflection.has_cached_counter? + owner._read_attribute(reflection.counter_cache_column).to_i + else + scope.count(:all) + end # If there's nothing in the database and @target has no new records # we are certain the current target is an empty array. This is a @@ -62,14 +66,6 @@ module ActiveRecord [association_scope.limit_value, count].compact.min end - def counter_cache_value - reflection.has_cached_counter? ? owner._read_attribute(reflection.counter_cache_column).to_i : nil - end - - def find_target? - super && !counter_cache_value&.zero? - end - def update_counter(difference, reflection = reflection()) if reflection.has_cached_counter? owner.increment!(reflection.counter_cache_column, difference) diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 84a9797aa5..0d384950fe 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -57,21 +57,14 @@ module ActiveRecord @through_records[record.object_id] ||= begin ensure_mutable - through_record = through_association.build(*options_for_through_record) - through_record.send("#{source_reflection.name}=", record) + attributes = through_scope_attributes + attributes[source_reflection.name] = record + attributes[source_reflection.foreign_type] = options[:source_type] if options[:source_type] - if options[:source_type] - through_record.send("#{source_reflection.foreign_type}=", options[:source_type]) - end - - through_record + through_association.build(attributes) end end - def options_for_through_record - [through_scope_attributes] - end - def through_scope_attributes scope.where_values_hash(through_association.reflection.name.to_s). except!(through_association.reflection.foreign_key, diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index b76005b587..f35a40fb2f 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -64,16 +64,17 @@ module ActiveRecord end end - def initialize(base, table, associations) + def initialize(base, table, associations, join_type) tree = self.class.make_tree associations @join_root = JoinBase.new(base, table, build(tree, base)) + @join_type = join_type end def reflections join_root.drop(1).map!(&:reflection) end - def join_constraints(joins_to_add, join_type, alias_tracker) + def join_constraints(joins_to_add, alias_tracker) @alias_tracker = alias_tracker construct_tables!(join_root) @@ -82,9 +83,9 @@ module ActiveRecord joins.concat joins_to_add.flat_map { |oj| construct_tables!(oj.join_root) if join_root.match? oj.join_root - walk join_root, oj.join_root + walk(join_root, oj.join_root, oj.join_type) else - make_join_constraints(oj.join_root, join_type) + make_join_constraints(oj.join_root, oj.join_type) end } end @@ -125,7 +126,7 @@ module ActiveRecord end protected - attr_reader :join_root + attr_reader :join_root, :join_type private attr_reader :alias_tracker @@ -151,7 +152,7 @@ module ActiveRecord end end - def make_constraints(parent, child, join_type = Arel::Nodes::OuterJoin) + def make_constraints(parent, child, join_type) foreign_table = parent.table foreign_klass = parent.base_klass joins = child.join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) @@ -173,13 +174,13 @@ module ActiveRecord join ? "#{name}_join" : name end - def walk(left, right) + def walk(left, right, join_type) intersection, missing = right.children.map { |node1| [left.children.find { |node2| node1.match? node2 }, node1] }.partition(&:first) - joins = intersection.flat_map { |l, r| r.table = l.table; walk(l, r) } - joins.concat missing.flat_map { |_, n| make_constraints(left, n) } + joins = intersection.flat_map { |l, r| r.table = l.table; walk(l, r, join_type) } + joins.concat missing.flat_map { |_, n| make_constraints(left, n, join_type) } end def find_reflection(klass, name) 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 4583d89cba..ca0305abbb 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_record/associations/join_dependency/join_part" +require "active_support/core_ext/array/extract" module ActiveRecord module Associations @@ -30,17 +31,21 @@ module ActiveRecord table = tables[-i] klass = reflection.klass - constraint = reflection.build_join_constraint(table, foreign_table) + join_scope = reflection.join_scope(table, foreign_table, foreign_klass) - joins << table.create_join(table, table.create_on(constraint), join_type) - - join_scope = reflection.join_scope(table, foreign_klass) arel = join_scope.arel(alias_tracker.aliases) + nodes = arel.constraints.first + + others = nodes.children.extract! do |node| + Arel.fetch_attribute(node) { |attr| attr.relation.name != table.name } + end + + joins << table.create_join(table, table.create_on(nodes), join_type) - if arel.constraints.any? + unless others.empty? joins.concat arel.join_sources right = joins.last.right - right.expr = right.expr.and(arel.constraints) + right.expr.children.concat(others) end # The current table in this iteration becomes the foreign table in the next diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 8997579527..6b57e5093a 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -143,16 +143,13 @@ module ActiveRecord def preloaders_for_reflection(reflection, records, scope) records.group_by { |record| record.association(reflection.name).klass }.map do |rhs_klass, rs| - loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) - loader.run self - loader + preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope).run end end def grouped_records(association, records, polymorphic_parent) h = {} records.each do |record| - next unless record reflection = record.class._reflect_on_association(association) next if polymorphic_parent && !reflection || !record.association(association).klass (h[reflection] ||= []) << record @@ -166,10 +163,18 @@ module ActiveRecord @reflection = reflection end - def run(preloader); end + def run + self + end def preloaded_records - owners.flat_map { |owner| owner.association(reflection.name).target } + @preloaded_records ||= records_by_owner.flat_map(&:last) + end + + def records_by_owner + @records_by_owner ||= owners.each_with_object({}) do |owner, result| + result[owner] = Array(owner.association(reflection.name).target) + end end private diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index 041e62077c..342d9e7a5a 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -4,29 +4,41 @@ module ActiveRecord module Associations class Preloader class Association #:nodoc: - attr_reader :preloaded_records - def initialize(klass, owners, reflection, preload_scope) @klass = klass @owners = owners @reflection = reflection @preload_scope = preload_scope @model = owners.first && owners.first.class - @preloaded_records = [] end - def run(preloader) - records = load_records do |record| - owner = owners_by_key[convert_key(record[association_key_name])] - association = owner.association(reflection.name) - association.set_inverse_instance(record) + def run + if !preload_scope || preload_scope.empty_scope? + owners.each do |owner| + associate_records_to_owner(owner, records_by_owner[owner] || []) + end + else + # Custom preload scope is used and + # the association can not be marked as loaded + # Loading into a Hash instead + records_by_owner end + self + end - owners.each do |owner| - associate_records_to_owner(owner, records[convert_key(owner[owner_key_name])] || []) + def records_by_owner + @records_by_owner ||= preloaded_records.each_with_object({}) do |record, result| + owners_by_key[convert_key(record[association_key_name])].each do |owner| + (result[owner] ||= []) << record + end end end + def preloaded_records + return @preloaded_records if defined?(@preloaded_records) + @preloaded_records = owner_keys.empty? ? [] : records_for(owner_keys) + end + private attr_reader :owners, :reflection, :preload_scope, :model, :klass @@ -54,13 +66,10 @@ module ActiveRecord end def owners_by_key - unless defined?(@owners_by_key) - @owners_by_key = owners.each_with_object({}) do |owner, h| - key = convert_key(owner[owner_key_name]) - h[key] = owner if key - end + @owners_by_key ||= owners.each_with_object({}) do |owner, result| + key = convert_key(owner[owner_key_name]) + (result[key] ||= []) << owner if key end - @owners_by_key end def key_conversion_required? @@ -87,23 +96,16 @@ module ActiveRecord @model.type_for_attribute(owner_key_name).type end - def load_records(&block) - return {} if owner_keys.empty? - # Some databases impose a limit on the number of ids in a list (in Oracle it's 1000) - # Make several smaller queries if necessary or make one query if the adapter supports it - slices = owner_keys.each_slice(klass.connection.in_clause_length || owner_keys.size) - @preloaded_records = slices.flat_map do |slice| - records_for(slice, &block) - end - @preloaded_records.group_by do |record| - convert_key(record[association_key_name]) + def records_for(ids) + scope.where(association_key_name => ids).load do |record| + # Processing only the first owner + # because the record is modified but not an owner + owner = owners_by_key[convert_key(record[association_key_name])].first + association = owner.association(reflection.name) + association.set_inverse_instance(record) end end - def records_for(ids, &block) - scope.where(association_key_name => ids).load(&block) - end - def scope @scope ||= build_scope end @@ -115,7 +117,7 @@ module ActiveRecord def build_scope scope = klass.scope_for_association - if reflection.type + if reflection.type && !reflection.through_reflection? scope.where!(reflection.type => model.polymorphic_name) end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index a6b7ab80a2..bec1c4c94a 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -4,42 +4,57 @@ module ActiveRecord module Associations class Preloader class ThroughAssociation < Association # :nodoc: - def run(preloader) - already_loaded = owners.first.association(through_reflection.name).loaded? - through_scope = through_scope() - reflection_scope = target_reflection_scope - through_preloaders = preloader.preload(owners, through_reflection.name, through_scope) - middle_records = through_preloaders.flat_map(&:preloaded_records) - preloaders = preloader.preload(middle_records, source_reflection.name, reflection_scope) - @preloaded_records = preloaders.flat_map(&:preloaded_records) - - owners.each do |owner| - through_records = Array(owner.association(through_reflection.name).target) - if already_loaded + PRELOADER = ActiveRecord::Associations::Preloader.new + + def initialize(*) + super + @already_loaded = owners.first.association(through_reflection.name).loaded? + end + + def preloaded_records + @preloaded_records ||= source_preloaders.flat_map(&:preloaded_records) + end + + def records_by_owner + return @records_by_owner if defined?(@records_by_owner) + source_records_by_owner = source_preloaders.map(&:records_by_owner).reduce(:merge) + through_records_by_owner = through_preloaders.map(&:records_by_owner).reduce(:merge) + + @records_by_owner = owners.each_with_object({}) do |owner, result| + through_records = through_records_by_owner[owner] || [] + + if @already_loaded if source_type = reflection.options[:source_type] through_records = through_records.select do |record| record[reflection.foreign_type] == source_type end end - else - owner.association(through_reflection.name).reset if through_scope - end - result = through_records.flat_map do |record| - association = record.association(source_reflection.name) - target = association.target - association.reset if preload_scope - target end - result.compact! - if reflection_scope - result.sort_by! { |rhs| preload_index[rhs] } if reflection_scope.order_values.any? - result.uniq! if reflection_scope.distinct_value + + records = through_records.flat_map do |record| + source_records_by_owner[record] end - associate_records_to_owner(owner, result) + + records.compact! + records.sort_by! { |rhs| preload_index[rhs] } if scope.order_values.any? + records.uniq! if scope.distinct_value + result[owner] = records end end private + def source_preloaders + @source_preloaders ||= PRELOADER.preload(middle_records, source_reflection.name, scope) + end + + def middle_records + through_preloaders.flat_map(&:preloaded_records) + end + + def through_preloaders + @through_preloaders ||= PRELOADER.preload(owners, through_reflection.name, through_scope) + end + def through_reflection reflection.through_reflection end @@ -49,8 +64,8 @@ module ActiveRecord end def preload_index - @preload_index ||= @preloaded_records.each_with_object({}).with_index do |(id, result), index| - result[id] = index + @preload_index ||= preloaded_records.each_with_object({}).with_index do |(record, result), index| + result[record] = index end end @@ -58,11 +73,15 @@ module ActiveRecord scope = through_reflection.klass.unscoped options = reflection.options + values = reflection_scope.values + if annotations = values[:annotate] + scope.annotate!(*annotations) + end + if options[:source_type] scope.where! reflection.foreign_type => options[:source_type] elsif !reflection_scope.where_clause.empty? scope.where_clause = reflection_scope.where_clause - values = reflection_scope.values if includes = values[:includes] scope.includes!(source_reflection.name => includes) @@ -89,17 +108,7 @@ module ActiveRecord end end - scope unless scope.empty_scope? - end - - def target_reflection_scope - if preload_scope - reflection_scope.merge(preload_scope) - elsif reflection.scope - reflection_scope - else - nil - end + scope end end end diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index fd8c1da842..220043c061 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -35,7 +35,8 @@ module ActiveRecord end def initialize_generated_modules # :nodoc: - @generated_attribute_methods = GeneratedAttributeMethods.new + @generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethods.new) + private_constant :GeneratedAttributeMethods @attribute_methods_generated = false include @generated_attribute_methods @@ -196,7 +197,7 @@ module ActiveRecord "Dangerous query method (method whose arguments are used as raw " \ "SQL) called with non-attribute argument(s): " \ "#{unexpected.map(&:inspect).join(", ")}. Non-attribute " \ - "arguments will be disallowed in Rails 6.0. This method should " \ + "arguments will be disallowed in Rails 6.1. This method should " \ "not be called with user-provided values, such as request " \ "parameters or model attributes. Known-safe values can be passed " \ "by wrapping them in Arel.sql()." @@ -464,7 +465,7 @@ module ActiveRecord end def pk_attribute?(name) - name == self.class.primary_key + name == @primary_key end end end diff --git a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb index 5941f51a1a..3d917ec9b1 100644 --- a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb +++ b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb @@ -46,6 +46,7 @@ module ActiveRecord # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" # task.read_attribute_before_type_cast(:completed_on) # => "2012-10-21" def read_attribute_before_type_cast(attr_name) + sync_with_transaction_state if @transaction_state&.finalized? @attributes[attr_name.to_s].value_before_type_cast end @@ -60,17 +61,19 @@ module ActiveRecord # task.attributes_before_type_cast # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>"2012-10-21", "created_at"=>nil, "updated_at"=>nil} def attributes_before_type_cast + sync_with_transaction_state if @transaction_state&.finalized? @attributes.values_before_type_cast end private - # Handle *_before_type_cast for method_missing. + # Dispatch target for <tt>*_before_type_cast</tt> attribute methods. def attribute_before_type_cast(attribute_name) read_attribute_before_type_cast(attribute_name) end def attribute_came_from_user?(attribute_name) + sync_with_transaction_state if @transaction_state&.finalized? @attributes[attribute_name].came_from_user? end end diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb index 45e4b8adfa..a43ebdf60d 100644 --- a/activerecord/lib/active_record/attribute_methods/dirty.rb +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -29,9 +29,7 @@ module ActiveRecord # <tt>reload</tt> the record and clears changed attributes. def reload(*) super.tap do - @previously_changed = ActiveSupport::HashWithIndifferentAccess.new @mutations_before_last_save = nil - @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new @mutations_from_database = nil end end @@ -51,7 +49,7 @@ module ActiveRecord # +to+ When passed, this method will return false unless the value was # changed to the given value def saved_change_to_attribute?(attr_name, **options) - mutations_before_last_save.changed?(attr_name, **options) + mutations_before_last_save.changed?(attr_name.to_s, options) end # Returns the change to an attribute during the last save. If the @@ -63,7 +61,7 @@ module ActiveRecord # invoked as +saved_change_to_name+ instead of # <tt>saved_change_to_attribute("name")</tt>. def saved_change_to_attribute(attr_name) - mutations_before_last_save.change_to_attribute(attr_name) + mutations_before_last_save.change_to_attribute(attr_name.to_s) end # Returns the original value of an attribute before the last save. @@ -73,7 +71,7 @@ module ActiveRecord # invoked as +name_before_last_save+ instead of # <tt>attribute_before_last_save("name")</tt>. def attribute_before_last_save(attr_name) - mutations_before_last_save.original_value(attr_name) + mutations_before_last_save.original_value(attr_name.to_s) end # Did the last call to +save+ have any changes to change? @@ -101,7 +99,7 @@ module ActiveRecord # +to+ When passed, this method will return false unless the value will be # changed to the given value def will_save_change_to_attribute?(attr_name, **options) - mutations_from_database.changed?(attr_name, **options) + mutations_from_database.changed?(attr_name.to_s, options) end # Returns the change to an attribute that will be persisted during the @@ -115,7 +113,7 @@ module ActiveRecord # If the attribute will change, the result will be an array containing the # original value and the new value about to be saved. def attribute_change_to_be_saved(attr_name) - mutations_from_database.change_to_attribute(attr_name) + mutations_from_database.change_to_attribute(attr_name.to_s) end # Returns the value of an attribute in the database, as opposed to the @@ -127,7 +125,7 @@ module ActiveRecord # saved. It can be invoked as +name_in_database+ instead of # <tt>attribute_in_database("name")</tt>. def attribute_in_database(attr_name) - mutations_from_database.original_value(attr_name) + mutations_from_database.original_value(attr_name.to_s) end # Will the next call to +save+ have any changes to persist? @@ -158,16 +156,46 @@ module ActiveRecord end private + def mutations_from_database + sync_with_transaction_state if @transaction_state&.finalized? + super + end + + def mutations_before_last_save + sync_with_transaction_state if @transaction_state&.finalized? + super + end + def write_attribute_without_type_cast(attr_name, value) - name = attr_name.to_s - if self.class.attribute_alias?(name) - name = self.class.attribute_alias(name) - end - result = super(name, value) - clear_attribute_change(name) + result = super + clear_attribute_change(attr_name) result end + def _touch_row(attribute_names, time) + @_touch_attr_names = Set.new(attribute_names) + + affected_rows = super + + changes = {} + @attributes.keys.each do |attr_name| + next if @_touch_attr_names.include?(attr_name) + + if attribute_changed?(attr_name) + changes[attr_name] = _read_attribute(attr_name) + _write_attribute(attr_name, attribute_was(attr_name)) + clear_attribute_change(attr_name) + end + end + + changes_applied + changes.each { |attr_name, value| _write_attribute(attr_name, value) } + + affected_rows + ensure + @_touch_attr_names = nil + end + def _update_record(attribute_names = attribute_names_for_partial_writes) affected_rows = super changes_applied diff --git a/activerecord/lib/active_record/attribute_methods/primary_key.rb b/activerecord/lib/active_record/attribute_methods/primary_key.rb index 6af5346fa7..b4f5e6e75a 100644 --- a/activerecord/lib/active_record/attribute_methods/primary_key.rb +++ b/activerecord/lib/active_record/attribute_methods/primary_key.rb @@ -16,40 +16,32 @@ module ActiveRecord # Returns the primary key column's value. def id - sync_with_transaction_state - primary_key = self.class.primary_key - _read_attribute(primary_key) if primary_key + _read_attribute(@primary_key) end # Sets the primary key column's value. def id=(value) - sync_with_transaction_state - primary_key = self.class.primary_key - _write_attribute(primary_key, value) if primary_key + _write_attribute(@primary_key, value) end # Queries the primary key column's value. def id? - sync_with_transaction_state - query_attribute(self.class.primary_key) + query_attribute(@primary_key) end # Returns the primary key column's value before type cast. def id_before_type_cast - sync_with_transaction_state - read_attribute_before_type_cast(self.class.primary_key) + read_attribute_before_type_cast(@primary_key) end # Returns the primary key column's previous value. def id_was - sync_with_transaction_state - attribute_was(self.class.primary_key) + attribute_was(@primary_key) end # Returns the primary key column's value from the database. def id_in_database - sync_with_transaction_state - attribute_in_database(self.class.primary_key) + attribute_in_database(@primary_key) end private @@ -122,7 +114,7 @@ module ActiveRecord # # Project.primary_key # => "foo_id" def primary_key=(value) - @primary_key = value && value.to_s + @primary_key = value && -value.to_s @quoted_primary_key = nil @attributes_builder = nil end diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb index 6757e9b66a..0cf67644af 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -16,8 +16,7 @@ module ActiveRecord when true then true when false, nil then false else - column = self.class.columns_hash[attr_name] - if column.nil? + if !type_for_attribute(attr_name) { false } if Numeric === value || value !~ /[^0-9]/ !value.to_i.zero? else @@ -33,7 +32,7 @@ module ActiveRecord end private - # Handle *? for method_missing. + # Dispatch target for <tt>*?</tt> attribute methods. def attribute?(attribute_name) query_attribute(attribute_name) end diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index ffac5313ad..0562327a9a 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -9,14 +9,11 @@ module ActiveRecord private def define_method_attribute(name) - sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key - ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( generated_attribute_methods, name ) do |temp_method_name, attr_name_expr| generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{temp_method_name} - #{sync_with_transaction_state} name = #{attr_name_expr} _read_attribute(name) { |n| missing_attribute(n, caller) } end @@ -30,19 +27,16 @@ module ActiveRecord # to a date object, like Date.new(2004, 12, 12)). def read_attribute(attr_name, &block) name = attr_name.to_s - if self.class.attribute_alias?(name) - name = self.class.attribute_alias(name) - end + name = self.class.attribute_aliases[name] || name - primary_key = self.class.primary_key - name = primary_key if name == "id" && primary_key - sync_with_transaction_state if name == primary_key + name = @primary_key if name == "id" && @primary_key _read_attribute(name, &block) end # This method exists to avoid the expensive primary_key check internally, without # breaking compatibility with the read_attribute API def _read_attribute(attr_name, &block) # :nodoc + sync_with_transaction_state if @transaction_state&.finalized? @attributes.fetch_value(attr_name.to_s, &block) end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index 455e67e19b..1c63b553d0 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -13,15 +13,12 @@ module ActiveRecord private def define_method_attribute=(name) - sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key - ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( generated_attribute_methods, name, writer: true, ) do |temp_method_name, attr_name_expr| generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{temp_method_name}(value) name = #{attr_name_expr} - #{sync_with_transaction_state} _write_attribute(name, value) end RUBY @@ -34,31 +31,28 @@ module ActiveRecord # turned into +nil+. def write_attribute(attr_name, value) name = attr_name.to_s - if self.class.attribute_alias?(name) - name = self.class.attribute_alias(name) - end + name = self.class.attribute_aliases[name] || name - primary_key = self.class.primary_key - name = primary_key if name == "id" && primary_key - sync_with_transaction_state if name == primary_key + name = @primary_key if name == "id" && @primary_key _write_attribute(name, value) end # This method exists to avoid the expensive primary_key check internally, without # breaking compatibility with the write_attribute API def _write_attribute(attr_name, value) # :nodoc: + sync_with_transaction_state if @transaction_state&.finalized? @attributes.write_from_user(attr_name.to_s, value) value end private def write_attribute_without_type_cast(attr_name, value) - name = attr_name.to_s - @attributes.write_cast_value(name, value) + sync_with_transaction_state if @transaction_state&.finalized? + @attributes.write_cast_value(attr_name.to_s, value) value end - # Handle *= for method_missing. + # Dispatch target for <tt>*=</tt> attribute methods. def attribute=(attribute_name, value) _write_attribute(attribute_name, value) end diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index 35150889d9..7cf421c184 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -41,6 +41,9 @@ module ActiveRecord # +range+ (PostgreSQL only) specifies that the type should be a range (see the # examples below). # + # When using a symbol for +cast_type+, extra options are forwarded to the + # constructor of the type object. + # # ==== Examples # # The type detected by Active Record can be overridden. @@ -112,6 +115,16 @@ module ActiveRecord # my_float_range: 1.0..3.5 # } # + # Passing options to the type constructor + # + # # app/models/my_model.rb + # class MyModel < ActiveRecord::Base + # attribute :small_int, :integer, limit: 2 + # end + # + # MyModel.create(small_int: 65537) + # # => Error: 65537 is out of range for the limit of two bytes + # # ==== Creating Custom Types # # Users may also define their own custom types, as long as they respond diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index d77d76cb1e..8d89e7d84a 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -330,21 +330,16 @@ module ActiveRecord if reflection.options[:autosave] indexed_attribute = !index.nil? && (reflection.options[:index_errors] || ActiveRecord::Base.index_nested_attribute_errors) - record.errors.each do |attribute, message| + record.errors.group_by_attribute.each { |attribute, errors| attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute) - errors[attribute] << message - errors[attribute].uniq! - end - - record.errors.details.each_key do |attribute| - reflection_attribute = - normalize_reflection_attribute(indexed_attribute, reflection, index, attribute).to_sym - record.errors.details[attribute].each do |error| - errors.details[reflection_attribute] << error - errors.details[reflection_attribute].uniq! - end - end + errors.each { |error| + self.errors.import( + error, + attribute: attribute + ) + } + } else errors.add(reflection.name) end @@ -382,10 +377,14 @@ module ActiveRecord if association = association_instance_get(reflection.name) autosave = reflection.options[:autosave] + # By saving the instance variable in a local variable, + # we make the whole callback re-entrant. + new_record_before_save = @new_record_before_save + # reconstruct the scope now that we know the owner's id association.reset_scope - if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave) + if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave) if autosave records_to_destroy = records.select(&:marked_for_destruction?) records_to_destroy.each { |record| association.destroy(record) } @@ -397,7 +396,7 @@ module ActiveRecord saved = true - if autosave != false && (@new_record_before_save || record.new_record?) + if autosave != false && (new_record_before_save || record.new_record?) if autosave saved = association.insert_record(record, false) elsif !reflection.nested? @@ -457,10 +456,16 @@ module ActiveRecord # If the record is new or it has changed, returns true. def record_changed?(reflection, record, key) record.new_record? || - (record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key) || + association_foreign_key_changed?(reflection, record, key) || record.will_save_change_to_attribute?(reflection.foreign_key) end + def association_foreign_key_changed?(reflection, record, key) + return false if reflection.through_reflection? + + record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key + end + # Saves the associated record if it's new or <tt>:autosave</tt> is enabled. # # In addition, it will destroy the association if it was marked for destruction. @@ -490,9 +495,7 @@ module ActiveRecord end def _ensure_no_duplicate_errors - errors.messages.each_key do |attribute| - errors[attribute].uniq! - end + errors.uniq! end end end diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index db097cb930..2af6d09b53 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -288,7 +288,6 @@ module ActiveRecord #:nodoc: extend Explain extend Enum extend Delegation::DelegateCache - extend CollectionCacheKey extend Aggregations::ClassMethods include Core diff --git a/activerecord/lib/active_record/callbacks.rb b/activerecord/lib/active_record/callbacks.rb index 5407af85ea..ef5444dfc3 100644 --- a/activerecord/lib/active_record/callbacks.rb +++ b/activerecord/lib/active_record/callbacks.rb @@ -95,7 +95,7 @@ module ActiveRecord # # private # def delete_parents - # self.class.where(parent_id: id).delete_all + # self.class.delete_by(parent_id: id) # end # end # @@ -324,7 +324,7 @@ module ActiveRecord private - def create_or_update(*) + def create_or_update(**) _run_save_callbacks { super } end @@ -332,7 +332,7 @@ module ActiveRecord _run_create_callbacks { super } end - def _update_record(*) + def _update_record _run_update_callbacks { super } end end diff --git a/activerecord/lib/active_record/collection_cache_key.rb b/activerecord/lib/active_record/collection_cache_key.rb deleted file mode 100644 index 4b6db8a96c..0000000000 --- a/activerecord/lib/active_record/collection_cache_key.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module CollectionCacheKey - def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: - query_signature = ActiveSupport::Digest.hexdigest(collection.to_sql) - key = "#{collection.model_name.cache_key}/query-#{query_signature}" - - if collection.loaded? || collection.distinct_value - size = collection.records.size - if size > 0 - timestamp = collection.max_by(×tamp_column)._read_attribute(timestamp_column) - end - else - if collection.eager_loading? - collection = collection.send(:apply_join_dependency) - end - column_type = type_for_attribute(timestamp_column) - column = connection.visitor.compile(collection.arel_attribute(timestamp_column)) - select_values = "COUNT(*) AS #{connection.quote_column_name("size")}, MAX(%s) AS timestamp" - - if collection.has_limit_or_offset? - query = collection.select("#{column} AS collection_cache_key_timestamp") - subquery_alias = "subquery_for_cache_key" - subquery_column = "#{subquery_alias}.collection_cache_key_timestamp" - subquery = query.arel.as(subquery_alias) - arel = Arel::SelectManager.new(subquery).project(select_values % subquery_column) - else - query = collection.unscope(:order) - query.select_values = [select_values % column] - arel = query.arel - end - - result = connection.select_one(arel, nil) - - if result.blank? - size = 0 - timestamp = nil - else - size = result["size"] - timestamp = column_type.deserialize(result["timestamp"]) - end - - end - - if timestamp - "#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" - else - "#{key}-#{size}" - end - end - end -end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 1c8df3c08a..68498b5dc5 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -810,6 +810,7 @@ module ActiveRecord def new_connection Base.send(spec.adapter_method, spec.config).tap do |conn| conn.schema_cache = schema_cache.dup if schema_cache + conn.check_version end end @@ -915,6 +916,16 @@ module ActiveRecord # about the model. The model needs to pass a specification name to the handler, # in order to look up the correct connection pool. class ConnectionHandler + def self.create_owner_to_pool # :nodoc: + Concurrent::Map.new(initial_capacity: 2) do |h, k| + # Discard the parent's connection pools immediately; we have no need + # of them + discard_unowned_pools(h) + + h[k] = Concurrent::Map.new(initial_capacity: 2) + end + end + def self.unowned_pool_finalizer(pid_map) # :nodoc: lambda do |_| discard_unowned_pools(pid_map) @@ -929,13 +940,7 @@ module ActiveRecord def initialize # These caches are keyed by spec.name (ConnectionSpecification#name). - @owner_to_pool = Concurrent::Map.new(initial_capacity: 2) do |h, k| - # Discard the parent's connection pools immediately; we have no need - # of them - ConnectionHandler.discard_unowned_pools(h) - - h[k] = Concurrent::Map.new(initial_capacity: 2) - end + @owner_to_pool = ConnectionHandler.create_owner_to_pool # Backup finalizer: if the forked child never needed a pool, the above # early discard has not occurred diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb index 1305216be2..75e959045e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb @@ -5,20 +5,24 @@ require "active_support/deprecation" module ActiveRecord module ConnectionAdapters # :nodoc: module DatabaseLimits + def max_identifier_length # :nodoc: + 64 + end + # Returns the maximum length of a table alias. def table_alias_length - 255 + max_identifier_length end # Returns the maximum length of a column name. def column_name_length - 64 + max_identifier_length end deprecate :column_name_length # Returns the maximum length of a table name. def table_name_length - 64 + max_identifier_length end deprecate :table_name_length @@ -33,7 +37,7 @@ module ActiveRecord # Returns the maximum length of an index name. def index_name_length - 64 + max_identifier_length end # Returns the maximum number of columns per table. diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index aa2ecee74a..ef19538447 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -20,9 +20,22 @@ module ActiveRecord raise "Passing bind parameters with an arel AST is forbidden. " \ "The values must be stored on the AST directly" end - sql, binds = visitor.compile(arel_or_sql_string.ast, collector) - [sql.freeze, binds || []] + + if prepared_statements + sql, binds = visitor.compile(arel_or_sql_string.ast, collector) + + if binds.length > bind_params_length + unprepared_statement do + sql, binds = to_sql_and_binds(arel_or_sql_string) + visitor.preparable = false + end + end + else + sql = visitor.compile(arel_or_sql_string.ast, collector) + end + [sql.freeze, binds] else + visitor.preparable = false if prepared_statements [arel_or_sql_string.dup.freeze, binds] end end @@ -47,13 +60,8 @@ module ActiveRecord arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) - if !prepared_statements || (arel.is_a?(String) && preparable.nil?) - preparable = false - elsif binds.length > bind_params_length - sql, binds = unprepared_statement { to_sql_and_binds(arel) } - preparable = false - else - preparable = visitor.preparable + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false end if prepared_statements && preparable @@ -123,7 +131,7 @@ module ActiveRecord # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil) - sql, binds = sql_for_insert(sql, pk, sequence_name, binds) + sql, binds = sql_for_insert(sql, pk, binds) exec_query(sql, name, binds) end @@ -134,11 +142,6 @@ module ActiveRecord exec_query(sql, name, binds) end - # Executes the truncate statement. - def truncate(table_name, name = nil) - raise NotImplementedError - end - # Executes update +sql+ statement in the context of this connection using # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. @@ -173,6 +176,23 @@ module ActiveRecord exec_delete(sql, name, binds) end + # Executes the truncate statement. + def truncate(table_name, name = nil) + execute(build_truncate_statements(table_name), name) + end + + def truncate_tables(*table_names) # :nodoc: + return if table_names.empty? + + with_multi_statements do + disable_referential_integrity do + Array(build_truncate_statements(*table_names)).each do |sql| + execute_batch(sql, "Truncate Tables") + end + end + end + end + # Runs the given block in a database transaction, and returns the result # of the block. # @@ -333,46 +353,20 @@ module ActiveRecord # We keep this method to provide fallback # for databases like sqlite that do not support bulk inserts. def insert_fixture(fixture, table_name) - fixture = fixture.stringify_keys - - columns = schema_cache.columns_hash(table_name) - binds = fixture.map do |name, value| - if column = columns[name] - type = lookup_cast_type_from_column(column) - Relation::QueryAttribute.new(name, value, type) - else - raise Fixture::FixtureError, %(table "#{table_name}" has no column named #{name.inspect}.) - end - end - - table = Arel::Table.new(table_name) - - values = binds.map do |bind| - value = with_yaml_fallback(bind.value_for_database) - [table[bind.name], value] - end - - manager = Arel::InsertManager.new - manager.into(table) - manager.insert(values) - execute manager.to_sql, "Fixture Insert" + execute(build_fixture_sql(Array.wrap(fixture), table_name), "Fixture Insert") end def insert_fixtures_set(fixture_set, tables_to_delete = []) - fixture_inserts = fixture_set.map do |table_name, fixtures| - next if fixtures.empty? - - build_fixture_sql(fixtures, table_name) - end.compact - - table_deletes = tables_to_delete.map { |table| +"DELETE FROM #{quote_table_name table}" } - total_sql = Array.wrap(combine_multi_statements(table_deletes + fixture_inserts)) - - disable_referential_integrity do - transaction(requires_new: true) do - total_sql.each do |sql| - execute sql, "Fixtures Load" - yield if block_given? + fixture_inserts = build_fixture_statements(fixture_set) + table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name(table)}" } + total_sql = Array(combine_multi_statements(table_deletes + fixture_inserts)) + + with_multi_statements do + disable_referential_integrity do + transaction(requires_new: true) do + total_sql.each do |sql| + execute_batch(sql, "Fixtures Load") + end end end end @@ -396,15 +390,33 @@ module ActiveRecord end end + # Fixture value is quoted by Arel, however scalar values + # are not quotable. In this case we want to convert + # the column value to YAML. + def with_yaml_fallback(value) # :nodoc: + if value.is_a?(Hash) || value.is_a?(Array) + YAML.dump(value) + else + value + end + end + private + def execute_batch(sql, name = nil) + execute(sql, name) + end + + DEFAULT_INSERT_VALUE = Arel.sql("DEFAULT").freeze + private_constant :DEFAULT_INSERT_VALUE + def default_insert_value(column) - Arel.sql("DEFAULT") + DEFAULT_INSERT_VALUE end def build_fixture_sql(fixtures, table_name) columns = schema_cache.columns_hash(table_name) - values = fixtures.map do |fixture| + values_list = fixtures.map do |fixture| fixture = fixture.stringify_keys unknown_columns = fixture.keys - columns.keys @@ -415,8 +427,7 @@ module ActiveRecord columns.map do |name, column| if fixture.key?(name) type = lookup_cast_type_from_column(column) - bind = Relation::QueryAttribute.new(name, fixture[name], type) - with_yaml_fallback(bind.value_for_database) + with_yaml_fallback(type.serialize(fixture[name])) else default_insert_value(column) end @@ -426,12 +437,43 @@ module ActiveRecord table = Arel::Table.new(table_name) manager = Arel::InsertManager.new manager.into(table) - columns.each_key { |column| manager.columns << table[column] } - manager.values = manager.create_values_list(values) + if values_list.size == 1 + values = values_list.shift + new_values = [] + columns.each_key.with_index { |column, i| + unless values[i].equal?(DEFAULT_INSERT_VALUE) + new_values << values[i] + manager.columns << table[column] + end + } + values_list << new_values + else + columns.each_key { |column| manager.columns << table[column] } + end + + manager.values = manager.create_values_list(values_list) manager.to_sql end + def build_fixture_statements(fixture_set) + fixture_set.map do |table_name, fixtures| + next if fixtures.empty? + build_fixture_sql(fixtures, table_name) + end.compact + end + + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "TRUNCATE TABLE #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + + def with_multi_statements + yield + end + def combine_multi_statements(total_sql) total_sql.join(";\n") end @@ -445,7 +487,7 @@ module ActiveRecord exec_query(sql, name, binds, prepare: true) end - def sql_for_insert(sql, pk, sequence_name, binds) + def sql_for_insert(sql, pk, binds) [sql, binds] end @@ -465,17 +507,6 @@ module ActiveRecord relation end end - - # Fixture value is quoted by Arel, however scalar values - # are not quotable. In this case we want to convert - # the column value to YAML. - def with_yaml_fallback(value) - if value.is_a?(Hash) || value.is_a?(Array) - YAML.dump(value) - else - value - end - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 4e55fcae2f..a7753e3e9c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -7,7 +7,8 @@ module ActiveRecord module QueryCache class << self def included(base) #:nodoc: - dirties_query_cache base, :insert, :update, :delete, :rollback_to_savepoint, :rollback_db_transaction + dirties_query_cache base, :insert, :update, :delete, :truncate, :truncate_tables, + :rollback_to_savepoint, :rollback_db_transaction base.set_callback :checkout, :after, :configure_query_cache! base.set_callback :checkin, :after, :disable_query_cache! @@ -96,6 +97,11 @@ module ActiveRecord if @query_cache_enabled && !locked?(arel) arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) + + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false + end + cache_sql(sql, name, binds) { super(sql, name, binds, preparable: preparable) } else super diff --git a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb index eefe621feb..2877530917 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb @@ -138,15 +138,19 @@ module ActiveRecord "'#{quote_string(value.to_s)}'" end - def type_casted_binds(binds) # :nodoc: - if binds.first.is_a?(Array) - binds.map { |column, value| type_cast(value, column) } - else - binds.map { |attr| type_cast(attr.value_for_database) } - end + def sanitize_as_sql_comment(value) # :nodoc: + value.to_s.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "") end private + def type_casted_binds(binds) + if binds.first.is_a?(Array) + binds.map { |column, value| type_cast(value, column) } + else + binds.map { |attr| type_cast(attr.value_for_database) } + end + end + def lookup_cast_type(sql_type) type_map.lookup(sql_type) end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb b/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb index 52a796b926..d6dbef3fc8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb @@ -8,15 +8,15 @@ module ActiveRecord end def create_savepoint(name = current_savepoint_name) - execute("SAVEPOINT #{name}") + execute("SAVEPOINT #{name}", "TRANSACTION") end def exec_rollback_to_savepoint(name = current_savepoint_name) - execute("ROLLBACK TO SAVEPOINT #{name}") + execute("ROLLBACK TO SAVEPOINT #{name}", "TRANSACTION") end def release_savepoint(name = current_savepoint_name) - execute("RELEASE SAVEPOINT #{name}") + execute("RELEASE SAVEPOINT #{name}", "TRANSACTION") end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb index 2cb0a2a4df..7d20825a75 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb @@ -15,7 +15,7 @@ module ActiveRecord end delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql, - :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys_in_create?, :foreign_key_options, + :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys?, :foreign_key_options, to: :@conn, private: true private @@ -50,7 +50,7 @@ module ActiveRecord statements.concat(o.indexes.map { |column_name, options| index_in_create(o.name, column_name, options) }) end - if supports_foreign_keys_in_create? + if supports_foreign_keys? statements.concat(o.foreign_keys.map { |to_table, options| foreign_key_in_create(o.name, to_table, options) }) end @@ -127,6 +127,9 @@ module ActiveRecord end def foreign_key_in_create(from_table, to_table, options) + prefix = ActiveRecord::Base.table_name_prefix + suffix = ActiveRecord::Base.table_name_suffix + to_table = "#{prefix}#{to_table}#{suffix}" options = foreign_key_options(from_table, to_table, options) accept ForeignKeyDefinition.new(from_table, to_table, options) end 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 dfb4953cbd..688eea75e8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -102,16 +102,12 @@ module ActiveRecord alias validated? validate? def export_name_on_schema_dump? - name !~ ActiveRecord::SchemaDumper.fk_ignore_pattern + !ActiveRecord::SchemaDumper.fk_ignore_pattern.match?(name) if name end - def defined_for?(to_table_ord = nil, to_table: nil, **options) - if to_table_ord - self.to_table == to_table_ord.to_s - else - (to_table.nil? || to_table.to_s == self.to_table) && - options.all? { |k, v| self.options[k].to_s == v.to_s } - end + def defined_for?(to_table: nil, **options) + (to_table.nil? || to_table.to_s == self.to_table) && + options.all? { |k, v| self.options[k].to_s == v.to_s } end private @@ -208,7 +204,7 @@ module ActiveRecord ## # :method: column - # :call-seq: column(name, type, options = {}) + # :call-seq: column(name, type, **options) # # Appends a column or columns of a specified type. # @@ -229,6 +225,7 @@ module ActiveRecord column_types.each do |column_type| module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{column_type}(*names, **options) + raise ArgumentError, "Missing column name(s) for #{column_type}" if names.empty? names.each { |name| column(name, :#{column_type}, options) } end RUBY @@ -363,7 +360,7 @@ module ActiveRecord # t.references :tagger, polymorphic: true # t.references :taggable, polymorphic: { default: 'Photo' }, index: false # end - def column(name, type, options = {}) + def column(name, type, **options) name = name.to_s type = type.to_sym if type options = options.dup @@ -397,10 +394,7 @@ module ActiveRecord end def foreign_key(table_name, options = {}) # :nodoc: - table_name_prefix = ActiveRecord::Base.table_name_prefix - table_name_suffix = ActiveRecord::Base.table_name_suffix - table_name = "#{table_name_prefix}#{table_name}#{table_name_suffix}" - foreign_keys.push([table_name, options]) + foreign_keys << [table_name, options] end # Appends <tt>:datetime</tt> columns <tt>:created_at</tt> and @@ -422,6 +416,7 @@ module ActiveRecord # # t.references(:user) # t.belongs_to(:supplier, foreign_key: true) + # t.belongs_to(:supplier, foreign_key: true, type: :integer) # # See {connection.add_reference}[rdoc-ref:SchemaStatements#add_reference] for details of the options you can use. def references(*args, **options) @@ -521,6 +516,7 @@ module ActiveRecord # t.json # t.virtual # t.remove + # t.remove_foreign_key # t.remove_references # t.remove_belongs_to # t.remove_index @@ -542,7 +538,7 @@ module ActiveRecord # t.column(:name, :string) # # See TableDefinition#column for details of the options you can use. - def column(column_name, type, options = {}) + def column(column_name, type, **options) index_options = options.delete(:index) @base.add_column(name, column_name, type, options) index(column_name, index_options.is_a?(Hash) ? index_options : {}) if index_options @@ -694,6 +690,16 @@ module ActiveRecord @base.add_foreign_key(name, *args) end + # Removes the given foreign key from the table. + # + # t.remove_foreign_key(:authors) + # t.remove_foreign_key(column: :author_id) + # + # See {connection.remove_foreign_key}[rdoc-ref:SchemaStatements#remove_foreign_key] + def remove_foreign_key(*args) + @base.remove_foreign_key(name, *args) + end + # Checks to see if a foreign key exists. # # t.foreign_key(:authors) unless t.foreign_key_exists?(:authors) 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 d88e75d692..2b64e96450 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -584,7 +584,7 @@ module ActiveRecord # # Defines a column with a database-specific type. # add_column(:shapes, :triangle, 'polygon') # # ALTER TABLE "shapes" ADD "triangle" polygon - def add_column(table_name, column_name, type, options = {}) + def add_column(table_name, column_name, type, **options) at = create_alter_table table_name at.add_column(column_name, type, options) execute schema_creation.accept at @@ -770,6 +770,17 @@ module ActiveRecord # CREATE FULLTEXT INDEX index_developers_on_name ON developers (name) -- MySQL # # Note: only supported by MySQL. + # + # ====== Creating an index with a specific algorithm + # + # add_index(:developers, :name, algorithm: :concurrently) + # # CREATE INDEX CONCURRENTLY developers_on_name on developers (name) + # + # Note: only supported by PostgreSQL. + # + # Concurrently adding an index is not supported in a transaction. + # + # For more information see the {"Transactional Migrations" section}[rdoc-ref:Migration]. def add_index(table_name, column_name, options = {}) index_name, index_type, index_columns, index_options = add_index_options(table_name, column_name, options) execute "CREATE #{index_type} INDEX #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} (#{index_columns})#{index_options}" @@ -793,6 +804,15 @@ module ActiveRecord # # remove_index :accounts, name: :by_branch_party # + # Removes the index named +by_branch_party+ in the +accounts+ table +concurrently+. + # + # remove_index :accounts, name: :by_branch_party, algorithm: :concurrently + # + # Note: only supported by PostgreSQL. + # + # Concurrently removing an index is not supported in a transaction. + # + # For more information see the {"Transactional Migrations" section}[rdoc-ref:Migration]. def remove_index(table_name, options = {}) index_name = index_name_for_remove(table_name, options) execute "DROP INDEX #{quote_column_name(index_name)} ON #{quote_table_name(table_name)}" @@ -852,7 +872,7 @@ module ActiveRecord # [<tt>:null</tt>] # Whether the column allows nulls. Defaults to true. # - # ====== Create a user_id bigint column without a index + # ====== Create a user_id bigint column without an index # # add_reference(:products, :user, index: false) # @@ -966,7 +986,7 @@ module ActiveRecord # [<tt>:on_update</tt>] # Action that happens <tt>ON UPDATE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+ # [<tt>:validate</tt>] - # (Postgres only) Specify whether or not the constraint should be validated. Defaults to +true+. + # (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+. def add_foreign_key(from_table, to_table, options = {}) return unless supports_foreign_keys? @@ -1002,10 +1022,10 @@ module ActiveRecord # with an addition of # [<tt>:to_table</tt>] # The name of the table that contains the referenced primary key. - def remove_foreign_key(from_table, options_or_to_table = {}) + def remove_foreign_key(from_table, to_table = nil, **options) return unless supports_foreign_keys? - fk_name_to_delete = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_delete = foreign_key_for!(from_table, to_table: to_table, **options).name at = create_alter_table from_table at.drop_foreign_key fk_name_to_delete @@ -1024,14 +1044,12 @@ module ActiveRecord # # Checks to see if a foreign key with a custom name exists. # foreign_key_exists?(:accounts, name: "special_fk_name") # - def foreign_key_exists?(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table).present? + def foreign_key_exists?(from_table, to_table = nil, **options) + foreign_key_for(from_table, to_table: to_table, **options).present? end def foreign_key_column_for(table_name) # :nodoc: - prefix = Base.table_name_prefix - suffix = Base.table_name_suffix - name = table_name.to_s =~ /#{prefix}(.+)#{suffix}/ ? $1 : table_name.to_s + name = strip_table_name_prefix_and_suffix(table_name) "#{name.singularize}_id" end @@ -1053,7 +1071,7 @@ module ActiveRecord def assume_migrated_upto_version(version, migrations_paths = nil) unless migrations_paths.nil? - ActiveSupport::Deprecation.warn(<<~MSG) + ActiveSupport::Deprecation.warn(<<~MSG.squish) Passing migrations_paths to #assume_migrated_upto_version is deprecated and will be removed in Rails 6.1. MSG end @@ -1099,7 +1117,7 @@ module ActiveRecord if (0..6) === precision column_type_sql << "(#{precision})" else - raise(ActiveRecordError, "No #{native[:name]} type has precision of #{precision}. The allowed range of precision is from 0 to 6") + raise ArgumentError, "No #{native[:name]} type has precision of #{precision}. The allowed range of precision is from 0 to 6" end elsif (type != :primary_key) && (limit ||= native.is_a?(Hash) && native[:limit]) column_type_sql << "(#{limit})" @@ -1187,12 +1205,22 @@ module ActiveRecord end # Changes the comment for a table or removes it if +nil+. - def change_table_comment(table_name, comment) + # + # Passing a hash containing +:from+ and +:to+ will make this change + # reversible in migration: + # + # change_table_comment(:posts, from: "old_comment", to: "new_comment") + def change_table_comment(table_name, comment_or_changes) raise NotImplementedError, "#{self.class} does not support changing table comments" end # Changes the comment for a column or removes it if +nil+. - def change_column_comment(table_name, column_name, comment) + # + # Passing a hash containing +:from+ and +:to+ will make this change + # reversible in migration: + # + # change_column_comment(:posts, :state, from: "old_comment", to: "new_comment") + def change_column_comment(table_name, column_name, comment_or_changes) raise NotImplementedError, "#{self.class} does not support changing column comments" end @@ -1328,6 +1356,12 @@ module ActiveRecord { column: column_names } end + def strip_table_name_prefix_and_suffix(table_name) + prefix = Base.table_name_prefix + suffix = Base.table_name_suffix + table_name.to_s =~ /#{prefix}(.+)#{suffix}/ ? $1 : table_name.to_s + end + def foreign_key_name(table_name, options) options.fetch(:name) do identifier = "#{table_name}_#{options.fetch(:column)}_fk" @@ -1337,14 +1371,14 @@ module ActiveRecord end end - def foreign_key_for(from_table, options_or_to_table = {}) + def foreign_key_for(from_table, **options) return unless supports_foreign_keys? - foreign_keys(from_table).detect { |fk| fk.defined_for? options_or_to_table } + foreign_keys(from_table).detect { |fk| fk.defined_for?(options) } end - def foreign_key_for!(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table) || \ - raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options_or_to_table}") + def foreign_key_for!(from_table, to_table: nil, **options) + foreign_key_for(from_table, to_table: to_table, **options) || + raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") end def extract_foreign_key_action(specifier) @@ -1370,11 +1404,37 @@ module ActiveRecord default_or_changes end end + alias :extract_new_comment_value :extract_new_default_value def can_remove_index_by_name?(options) options.is_a?(Hash) && options.key?(:name) && options.except(:name, :algorithm).empty? end + def bulk_change_table(table_name, operations) + sql_fragments = [] + non_combinable_operations = [] + + operations.each do |command, args| + table, arguments = args.shift, args + method = :"#{command}_for_alter" + + if respond_to?(method, true) + sqls, procs = Array(send(method, table, *arguments)).partition { |v| v.is_a?(String) } + sql_fragments << sqls + non_combinable_operations.concat(procs) + else + execute "ALTER TABLE #{quote_table_name(table_name)} #{sql_fragments.join(", ")}" unless sql_fragments.empty? + non_combinable_operations.each(&:call) + sql_fragments = [] + non_combinable_operations = [] + send(command, table, *arguments) + end + end + + execute "ALTER TABLE #{quote_table_name(table_name)} #{sql_fragments.join(", ")}" unless sql_fragments.empty? + non_combinable_operations.each(&:call) + end + def add_column_for_alter(table_name, column_name, type, options = {}) td = create_table_definition(table_name) cd = td.new_column_definition(column_name, type, options) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index c9e84e48cc..7b6321d83b 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -98,9 +98,13 @@ module ActiveRecord end def rollback_records - ite = records.uniq + ite = records.uniq(&:object_id) + already_run_callbacks = {} while record = ite.shift - record.rolledback!(force_restore_state: full_rollback?) + trigger_callbacks = record.trigger_transactional_callbacks? + should_run_callbacks = !already_run_callbacks[record] && trigger_callbacks + already_run_callbacks[record] ||= trigger_callbacks + record.rolledback!(force_restore_state: full_rollback?, should_run_callbacks: should_run_callbacks) end ensure ite.each do |i| @@ -113,10 +117,14 @@ module ActiveRecord end def commit_records - ite = records.uniq + ite = records.uniq(&:object_id) + already_run_callbacks = {} while record = ite.shift if @run_commit_callbacks - record.committed! + trigger_callbacks = record.trigger_transactional_callbacks? + should_run_callbacks = !already_run_callbacks[record] && trigger_callbacks + already_run_callbacks[record] ||= trigger_callbacks + record.committed!(should_run_callbacks: should_run_callbacks) else # if not running callbacks, only adds the record to the parent transaction connection.add_transaction_record(record) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 9a7d7285f2..bf0bb84c93 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -102,7 +102,7 @@ module ActiveRecord end def self.build_read_query_regexp(*parts) # :nodoc: - parts = parts.map { |part| /\A\s*#{part}/i } + parts = parts.map { |part| /\A[\(\s]*#{part}/i } Regexp.union(*parts) end @@ -120,6 +120,7 @@ module ActiveRecord @quoted_column_names, @quoted_table_names = {}, {} @prevent_writes = false @visitor = arel_visitor + @statements = build_statement_pool @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) @@ -132,8 +133,6 @@ module ActiveRecord @advisory_locks_enabled = self.class.type_cast_config_to_boolean( config.fetch(:advisory_locks, true) ) - - check_version end def replica? @@ -171,8 +170,11 @@ module ActiveRecord class Version include Comparable - def initialize(version_string) + attr_reader :full_version_string + + def initialize(version_string, full_version_string = nil) @version = version_string.split(".").map(&:to_i) + @full_version_string = full_version_string end def <=>(version_string) @@ -335,6 +337,7 @@ module ActiveRecord def supports_foreign_keys_in_create? supports_foreign_keys? end + deprecate :supports_foreign_keys_in_create? # Does this adapter support views? def supports_views? @@ -382,10 +385,31 @@ module ActiveRecord false end + # Does this adapter support optimizer hints? + def supports_optimizer_hints? + false + end + def supports_lazy_transactions? false end + def supports_insert_returning? + false + end + + def supports_insert_on_duplicate_skip? + false + end + + def supports_insert_on_duplicate_update? + false + end + + def supports_insert_conflict_target? + false + end + # This is meant to be implemented by the adapters that support extensions def disable_extension(name) end @@ -475,11 +499,9 @@ module ActiveRecord # this should be overridden by concrete adapters end - ### - # Clear any caching the database adapter may be doing, for example - # clearing the prepared statement cache. This is database specific. + # Clear any caching the database adapter may be doing. def clear_cache! - # this should be overridden by concrete adapters + @lock.synchronize { @statements.clear } if @statements end # Returns true if its required to reload the connection between requests for development mode. @@ -505,6 +527,10 @@ module ActiveRecord @connection end + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + attribute.eq(value) + end + def case_sensitive_comparison(attribute, value) # :nodoc: attribute.eq(value) end @@ -537,10 +563,31 @@ module ActiveRecord index.using.nil? end - private - def check_version + # Called by ActiveRecord::InsertAll, + # Passed an instance of ActiveRecord::InsertAll::Builder, + # This method implements standard bulk inserts for all databases, but + # should be overridden by adapters to implement common features with + # non-standard syntax like handling duplicates or returning values. + def build_insert_sql(insert) # :nodoc: + if insert.skip_duplicates? || insert.update_duplicates? + raise NotImplementedError, "#{self.class} should define `build_insert_sql` to implement adapter-specific logic for handling duplicates during INSERT" end + "INSERT #{insert.into} #{insert.values_list}" + end + + def get_database_version # :nodoc: + end + + def database_version # :nodoc: + schema_cache.database_version + end + + def check_version # :nodoc: + end + + private + def type_map @type_map ||= Type::TypeMap.new.tap do |mapping| initialize_type_map(mapping) @@ -684,6 +731,9 @@ module ActiveRecord def arel_visitor Arel::Visitors::ToSql.new(self) end + + def build_statement_pool + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index 569c146f92..d239ecff89 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -53,28 +53,28 @@ module ActiveRecord def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) end - def version #:nodoc: - @version ||= Version.new(version_string) + def get_database_version #:nodoc: + full_version_string = get_full_version + version_string = version_string(full_version_string) + Version.new(version_string, full_version_string) end def mariadb? # :nodoc: /mariadb/i.match?(full_version) end - def supports_bulk_alter? #:nodoc: + def supports_bulk_alter? true end def supports_index_sort_order? - !mariadb? && version >= "8.0.1" + !mariadb? && database_version >= "8.0.1" end def supports_expression_index? - !mariadb? && version >= "8.0.13" + !mariadb? && database_version >= "8.0.13" end def supports_transaction_isolation? @@ -98,17 +98,30 @@ module ActiveRecord end def supports_datetime_with_precision? - mariadb? || version >= "5.6.4" + mariadb? || database_version >= "5.6.4" end def supports_virtual_columns? - mariadb? || version >= "5.7.5" + mariadb? || database_version >= "5.7.5" + end + + # See https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html for more details. + def supports_optimizer_hints? + !mariadb? && database_version >= "5.7.7" end def supports_advisory_locks? true end + def supports_insert_on_duplicate_skip? + true + end + + def supports_insert_on_duplicate_update? + true + end + def get_advisory_lock(lock_name, timeout = 0) # :nodoc: query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1 end @@ -154,10 +167,9 @@ module ActiveRecord # CONNECTION MANAGEMENT ==================================== - # Clears the prepared statements cache. - def clear_cache! + def clear_cache! # :nodoc: reload_type_map - @statements.clear + super end #-- @@ -192,7 +204,7 @@ module ActiveRecord end def begin_db_transaction - execute "BEGIN" + execute("BEGIN", "TRANSACTION") end def begin_isolated_db_transaction(isolation) @@ -201,11 +213,11 @@ module ActiveRecord end def commit_db_transaction #:nodoc: - execute "COMMIT" + execute("COMMIT", "TRANSACTION") end def exec_rollback_db_transaction #:nodoc: - execute "ROLLBACK" + execute("ROLLBACK", "TRANSACTION") end def empty_insert_statement_value(primary_key = nil) @@ -264,10 +276,6 @@ module ActiveRecord show_variable "collation_database" end - def truncate(table_name, name = nil) - execute "TRUNCATE TABLE #{quote_table_name(table_name)}", name - end - def table_comment(table_name) # :nodoc: scope = quoted_scope(table_name) @@ -279,22 +287,8 @@ module ActiveRecord SQL end - def bulk_change_table(table_name, operations) #:nodoc: - sqls = operations.flat_map do |command, args| - table, arguments = args.shift, args - method = :"#{command}_for_alter" - - if respond_to?(method, true) - send(method, table, *arguments) - else - raise "Unknown method called : #{method}(#{arguments.inspect})" - end - end.join(", ") - - execute("ALTER TABLE #{quote_table_name(table_name)} #{sqls}") - end - - def change_table_comment(table_name, comment) #:nodoc: + def change_table_comment(table_name, comment_or_changes) # :nodoc: + comment = extract_new_comment_value(comment_or_changes) comment = "" if comment.nil? execute("ALTER TABLE #{quote_table_name(table_name)} COMMENT #{quote(comment)}") end @@ -350,7 +344,8 @@ module ActiveRecord change_column table_name, column_name, nil, null: null end - def change_column_comment(table_name, column_name, comment) #:nodoc: + def change_column_comment(table_name, column_name, comment_or_changes) # :nodoc: + comment = extract_new_comment_value(comment_or_changes) change_column table_name, column_name, nil, comment: comment end @@ -431,30 +426,6 @@ module ActiveRecord table_options end - # Maps logical Rails types to MySQL-specific data types. - def type_to_sql(type, limit: nil, precision: nil, scale: nil, unsigned: nil, **) # :nodoc: - sql = \ - case type.to_s - when "integer" - integer_to_sql(limit) - when "text" - text_to_sql(limit) - when "blob" - binary_to_sql(limit) - when "binary" - if (0..0xfff) === limit - "varbinary(#{limit})" - else - binary_to_sql(limit) - end - else - super - end - - sql = "#{sql} unsigned" if unsigned && type != :primary_key - sql - end - # SHOW VARIABLES LIKE 'name' def show_variable(name) query_value("SELECT @@#{name}", "SCHEMA") @@ -477,6 +448,21 @@ module ActiveRecord SQL end + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + column = column_for_attribute(attribute) + + if column.collation && !column.case_sensitive? && !value.nil? + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. + To continue case sensitive comparison on the :#{attribute.name} attribute in #{klass} model, + pass `case_sensitive: true` option explicitly to the uniqueness validator. + MSG + attribute.eq(Arel::Nodes::Bin.new(value)) + else + super + end + end + def case_sensitive_comparison(attribute, value) # :nodoc: column = column_for_attribute(attribute) @@ -515,45 +501,27 @@ module ActiveRecord index.using == :btree || super end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - with_multi_statements do - super { discard_remaining_results } - end - end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" - private - def check_version - if version < "5.5.8" - raise "Your version of MySQL (#{version_string}) is too old. Active Record supports MySQL >= 5.5.8." - end + if insert.skip_duplicates? + no_op_column = quote_column_name(insert.keys.first) + sql << " ON DUPLICATE KEY UPDATE #{no_op_column}=#{no_op_column}" + elsif insert.update_duplicates? + sql << " ON DUPLICATE KEY UPDATE " + sql << insert.updatable_columns.map { |column| "#{column}=VALUES(#{column})" }.join(",") end - def combine_multi_statements(total_sql) - total_sql.each_with_object([]) do |sql, total_sql_chunks| - previous_packet = total_sql_chunks.last - sql << ";\n" - if max_allowed_packet_reached?(sql, previous_packet) || total_sql_chunks.empty? - total_sql_chunks << sql - else - previous_packet << sql - end - end - end + sql + end - def max_allowed_packet_reached?(current_packet, previous_packet) - if current_packet.bytesize > max_allowed_packet - raise ActiveRecordError, "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." - elsif previous_packet.nil? - false - else - (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet - end + def check_version # :nodoc: + if database_version < "5.5.8" + raise "Your version of MySQL (#{database_version}) is too old. Active Record supports MySQL >= 5.5.8." end + end - def max_allowed_packet - bytes_margin = 2 - @max_allowed_packet ||= (show_variable("max_allowed_packet") - bytes_margin) - end + private def initialize_type_map(m = type_map) super @@ -724,7 +692,7 @@ module ActiveRecord end def supports_rename_index? - mariadb? ? false : version >= "5.7.6" + mariadb? ? false : database_version >= "5.7.6" end def configure_connection @@ -794,53 +762,36 @@ module ActiveRecord Arel::Visitors::MySQL.new(self) end + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + def mismatched_foreign_key(message, sql:, binds:) - parts = sql.scan(/`(\w+)`[ $)]/).flatten - MismatchedForeignKey.new( - self, + match = %r/ + (?:CREATE|ALTER)\s+TABLE\s*(?:`?\w+`?\.)?`?(?<table>\w+)`?.+? + FOREIGN\s+KEY\s*\(`?(?<foreign_key>\w+)`?\)\s* + REFERENCES\s*(`?(?<target_table>\w+)`?)\s*\(`?(?<primary_key>\w+)`?\) + /xmi.match(sql) + + options = { message: message, sql: sql, binds: binds, - table: parts[0], - foreign_key: parts[1], - target_table: parts[2], - primary_key: parts[3], - ) - end - - def integer_to_sql(limit) # :nodoc: - case limit - when 1; "tinyint" - when 2; "smallint" - when 3; "mediumint" - when nil, 4; "int" - when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead.") - end - end + } - def text_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinytext" - when nil, 0x100..0xffff; "text" - when 0x10000..0xffffff; "mediumtext" - when 0x1000000..0xffffffff; "longtext" - else raise(ActiveRecordError, "No text type has byte length #{limit}") + if match + options[:table] = match[:table] + options[:foreign_key] = match[:foreign_key] + options[:target_table] = match[:target_table] + options[:primary_key] = match[:primary_key] + options[:primary_key_column] = column_for(match[:target_table], match[:primary_key]) end - end - def binary_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinyblob" - when nil, 0x100..0xffff; "blob" - when 0x10000..0xffffff; "mediumblob" - when 0x1000000..0xffffffff; "longblob" - else raise(ActiveRecordError, "No binary type has byte length #{limit}") - end + MismatchedForeignKey.new(options) end - def version_string - full_version.match(/^(?:5\.5\.5-)?(\d+\.\d+\.\d+)/)[1] + def version_string(full_version_string) + full_version_string.match(/^(?:5\.5\.5-)?(\d+\.\d+\.\d+)/)[1] end class MysqlString < Type::String # :nodoc: diff --git a/activerecord/lib/active_record/connection_adapters/column.rb b/activerecord/lib/active_record/connection_adapters/column.rb index 5d81de9fe1..279d0b9e84 100644 --- a/activerecord/lib/active_record/connection_adapters/column.rb +++ b/activerecord/lib/active_record/connection_adapters/column.rb @@ -5,7 +5,7 @@ module ActiveRecord module ConnectionAdapters # An abstract definition of a column in a table. class Column - attr_reader :name, :default, :sql_type_metadata, :null, :table_name, :default_function, :collation, :comment + attr_reader :name, :default, :sql_type_metadata, :null, :default_function, :collation, :comment delegate :precision, :scale, :limit, :type, :sql_type, to: :sql_type_metadata, allow_nil: true @@ -15,9 +15,8 @@ module ActiveRecord # +default+ is the type-casted default value, such as +new+ in <tt>sales_stage varchar(20) default 'new'</tt>. # +sql_type_metadata+ is various information about the type of the column # +null+ determines if this column allows +NULL+ values. - def initialize(name, default, sql_type_metadata = nil, null = true, table_name = nil, default_function = nil, collation = nil, comment: nil, **) + def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation: nil, comment: nil, **) @name = name.freeze - @table_name = table_name @sql_type_metadata = sql_type_metadata @null = null @default = default @@ -44,7 +43,6 @@ module ActiveRecord def init_with(coder) @name = coder["name"] - @table_name = coder["table_name"] @sql_type_metadata = coder["sql_type_metadata"] @null = coder["null"] @default = coder["default"] @@ -55,7 +53,6 @@ module ActiveRecord def encode_with(coder) coder["name"] = @name - coder["table_name"] = @table_name coder["sql_type_metadata"] = @sql_type_metadata coder["null"] = @null coder["default"] = @default @@ -66,19 +63,26 @@ module ActiveRecord def ==(other) other.is_a?(Column) && - attributes_for_hash == other.attributes_for_hash + name == other.name && + default == other.default && + sql_type_metadata == other.sql_type_metadata && + null == other.null && + default_function == other.default_function && + collation == other.collation && + comment == other.comment end alias :eql? :== def hash - attributes_for_hash.hash + Column.hash ^ + name.hash ^ + default.hash ^ + sql_type_metadata.hash ^ + null.hash ^ + default_function.hash ^ + collation.hash ^ + comment.hash end - - protected - - def attributes_for_hash - [self.class, name, default, sql_type_metadata, null, table_name, default_function, collation] - end end class NullColumn < Column diff --git a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb index 883747b84b..1df4dea2d8 100644 --- a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb +++ b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb @@ -3,7 +3,7 @@ module ActiveRecord module ConnectionAdapters module DetermineIfPreparableVisitor - attr_reader :preparable + attr_accessor :preparable def accept(*) @preparable = true diff --git a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb index 6adcc14545..2132e5d248 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -11,7 +11,7 @@ module ActiveRecord else super end - discard_remaining_results + @connection.abandon_results! result end @@ -61,7 +61,9 @@ module ActiveRecord def exec_delete(sql, name = nil, binds = []) if without_prepared_statement?(binds) - execute_and_free(sql, name) { @connection.affected_rows } + @lock.synchronize do + execute_and_free(sql, name) { @connection.affected_rows } + end else exec_stmt_and_free(sql, name, binds) { |stmt| stmt.affected_rows } end @@ -69,22 +71,31 @@ module ActiveRecord alias :exec_update :exec_delete private + def execute_batch(sql, name = nil) + super + @connection.abandon_results! + end + def default_insert_value(column) - Arel.sql("DEFAULT") unless column.auto_increment? + super unless column.auto_increment? end def last_inserted_id(result) @connection.last_id end - def discard_remaining_results - @connection.abandon_results! - end - def supports_set_server_option? @connection.respond_to?(:set_server_option) end + def build_truncate_statements(*table_names) + if table_names.size == 1 + super.first + else + super + end + end + def multi_statements_enabled?(flags) if flags.is_a?(Array) flags.include?("MULTI_STATEMENTS") @@ -117,6 +128,33 @@ module ActiveRecord end end + def combine_multi_statements(total_sql) + total_sql.each_with_object([]) do |sql, total_sql_chunks| + previous_packet = total_sql_chunks.last + if max_allowed_packet_reached?(sql, previous_packet) + total_sql_chunks << +sql + else + previous_packet << ";\n" + previous_packet << sql + end + end + end + + def max_allowed_packet_reached?(current_packet, previous_packet) + if current_packet.bytesize > max_allowed_packet + raise ActiveRecordError, + "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." + elsif previous_packet.nil? + true + else + (current_packet.bytesize + previous_packet.bytesize + 2) > max_allowed_packet + end + end + + def max_allowed_packet + @max_allowed_packet ||= show_variable("max_allowed_packet") + end + def exec_stmt_and_free(sql, name, binds, cache_stmt: false) if preventing_writes? && write_query?(sql) raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index 8cb6b64fec..d21535a709 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -64,16 +64,6 @@ module ActiveRecord case type when :virtual type = options[:type] - when :text, :blob, :binary - case (size = options[:size])&.to_s - when "tiny", "medium", "long" - sql_type = @conn.native_database_types[type][:name] - type = "#{size}#{sql_type}" - else - raise ArgumentError, <<~MSG unless size.nil? - #{size.inspect} is invalid :size value. Only :tiny, :medium, and :long are allowed. - MSG - end when :primary_key type = :integer options[:limit] ||= 8 diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb index 57518b02fa..234fb25fdf 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb @@ -55,7 +55,7 @@ module ActiveRecord end def schema_collation(column) - if column.collation && table_name = column.table_name + if column.collation @table_collation_cache ||= {} @table_collation_cache[table_name] ||= @connection.exec_query("SHOW TABLE STATUS LIKE #{@connection.quote(table_name)}", "SCHEMA").first["Collation"] @@ -64,14 +64,14 @@ module ActiveRecord end def extract_expression_for_virtual_column(column) - if @connection.mariadb? && @connection.version < "10.2.5" - create_table_info = @connection.send(:create_table_info, column.table_name) + if @connection.mariadb? && @connection.database_version < "10.2.5" + create_table_info = @connection.send(:create_table_info, table_name) column_name = @connection.quote_column_name(column.name) if %r/#{column_name} #{Regexp.quote(column.sql_type)}(?: COLLATE \w+)? AS \((?<expression>.+?)\) #{column.extra}/ =~ create_table_info $~[:expression].inspect end else - scope = @connection.send(:quoted_scope, column.table_name) + scope = @connection.send(:quoted_scope, table_name) column_name = @connection.quote(column.name) sql = "SELECT generation_expression FROM information_schema.columns" \ " WHERE table_schema = #{scope[:schema]}" \ diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb index e9484a08de..25a1fb234a 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -97,14 +97,42 @@ module ActiveRecord MySQL::SchemaDumper.create(self, options) end + # Maps logical Rails types to MySQL-specific data types. + def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_size(limit, type), unsigned: nil, **) + sql = + case type.to_s + when "integer" + integer_to_sql(limit) + when "text" + type_with_size_to_sql("text", size) + when "blob" + type_with_size_to_sql("blob", size) + when "binary" + if (0..0xfff) === limit + "varbinary(#{limit})" + else + type_with_size_to_sql("blob", size) + end + else + super + end + + sql = "#{sql} unsigned" if unsigned && type != :primary_key + sql + end + + def table_alias_length + 256 # https://dev.mysql.com/doc/refman/8.0/en/identifiers.html + end + private CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"] def row_format_dynamic_by_default? if mariadb? - version >= "10.2.2" + database_version >= "10.2.2" else - version >= "5.7.9" + database_version >= "5.7.9" end end @@ -146,9 +174,8 @@ module ActiveRecord default, type_metadata, field[:Null] == "YES", - table_name, default_function, - field[:Collation], + collation: field[:Collation], comment: field[:Comment].presence ) end @@ -197,6 +224,40 @@ module ActiveRecord schema, name = nil, schema unless name [schema, name] end + + def type_with_size_to_sql(type, size) + case size&.to_s + when nil, "tiny", "medium", "long" + "#{size}#{type}" + else + raise ArgumentError, + "#{size.inspect} is invalid :size value. Only :tiny, :medium, and :long are allowed." + end + end + + def limit_to_size(limit, type) + case type.to_s + when "text", "blob", "binary" + case limit + when 0..0xff; "tiny" + when nil, 0x100..0xffff; nil + when 0x10000..0xffffff; "medium" + when 0x1000000..0xffffffff; "long" + else raise ArgumentError, "No #{type} type has byte size #{limit}" + end + end + end + + def integer_to_sql(limit) + case limit + when 1; "tinyint" + when 2; "smallint" + when 3; "mediumint" + when nil, 4; "int" + when 5..8; "bigint" + else raise ArgumentError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead." + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb index 7ad0944d51..9167593064 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb @@ -10,25 +10,21 @@ module ActiveRecord def initialize(type_metadata, extra: "") super(type_metadata) - @type_metadata = type_metadata @extra = extra end def ==(other) - other.is_a?(MySQL::TypeMetadata) && - attributes_for_hash == other.attributes_for_hash + other.is_a?(TypeMetadata) && + __getobj__ == other.__getobj__ && + extra == other.extra end alias eql? == def hash - attributes_for_hash.hash + TypeMetadata.hash ^ + __getobj__.hash ^ + extra.hash end - - protected - - def attributes_for_hash - [self.class, @type_metadata, extra] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 9bdaa00336..5b0335c22b 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -43,7 +43,7 @@ module ActiveRecord end def supports_json? - !mariadb? && version >= "5.7.8" + !mariadb? && database_version >= "5.7.8" end def supports_comments? @@ -126,7 +126,11 @@ module ActiveRecord end def full_version - @full_version ||= @connection.server_info[:version] + schema_cache.database_version.full_version_string + end + + def get_full_version + @connection.server_info[:version] end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb index 3ccc7271ab..ec25bb1e19 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb @@ -2,42 +2,29 @@ module ActiveRecord module ConnectionAdapters - # PostgreSQL-specific extensions to column definitions in a table. - class PostgreSQLColumn < Column #:nodoc: - delegate :array, :oid, :fmod, to: :sql_type_metadata - alias :array? :array + module PostgreSQL + class Column < ConnectionAdapters::Column # :nodoc: + delegate :oid, :fmod, to: :sql_type_metadata - def initialize(*, max_identifier_length: 63, **) - super - @max_identifier_length = max_identifier_length - end - - def serial? - return unless default_function - - if %r{\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z} =~ default_function - sequence_name_from_parts(table_name, name, suffix) == sequence_name + def initialize(*, serial: nil, **) + super + @serial = serial end - end - - private - attr_reader :max_identifier_length - def sequence_name_from_parts(table_name, column_name, suffix) - over_length = [table_name, column_name, suffix].map(&:length).sum + 2 - max_identifier_length - - if over_length > 0 - column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min - over_length -= column_name.length - column_name_length - column_name = column_name[0, column_name_length - [over_length, 0].min] - end + def serial? + @serial + end - if over_length > 0 - table_name = table_name[0, table_name.length - over_length] - end + def array + sql_type_metadata.sql_type.end_with?("[]") + end + alias :array? :array - "#{table_name}_#{column_name}_#{suffix}" + def sql_type + super.sub(/\[\]\z/, "") end + end end + PostgreSQLColumn = PostgreSQL::Column # :nodoc: end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb index 41633872e2..45ec79ca78 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -110,7 +110,7 @@ module ActiveRecord end alias :exec_update :exec_delete - def sql_for_insert(sql, pk, sequence_name, binds) # :nodoc: + def sql_for_insert(sql, pk, binds) # :nodoc: if pk.nil? # Extract the table from the insert sql. Yuck. table_ref = extract_table_ref_from_insert_sql(sql) @@ -145,7 +145,7 @@ module ActiveRecord # Begins a transaction. def begin_db_transaction - execute "BEGIN" + execute("BEGIN", "TRANSACTION") end def begin_isolated_db_transaction(isolation) @@ -155,15 +155,19 @@ module ActiveRecord # Commits a transaction. def commit_db_transaction - execute "COMMIT" + execute("COMMIT", "TRANSACTION") end # Aborts a transaction. def exec_rollback_db_transaction - execute "ROLLBACK" + execute("ROLLBACK", "TRANSACTION") end private + def build_truncate_statements(*table_names) + "TRUNCATE TABLE #{table_names.map(&method(:quote_table_name)).join(", ")}" + end + # Returns the current ID of a table's sequence. def last_insert_id_result(sequence_name) exec_query("SELECT currval(#{quote(sequence_name)})", "SQL") diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb index d85f9ab3ef..aa7701e038 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb @@ -64,7 +64,7 @@ module ActiveRecord end def type_cast_single_for_database(value) - infinity?(value) ? value : @subtype.serialize(value) + infinity?(value) ? value : @subtype.serialize(@subtype.cast(value)) end def extract_bounds(value) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb index bc9b8dbfcf..28abdbd073 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb @@ -13,9 +13,12 @@ module ActiveRecord :uuid end - def cast(value) - value.to_s[ACCEPTABLE_UUID, 0] - end + private + + def cast_value(value) + casted = value.to_s + casted if casted.match?(ACCEPTABLE_UUID) + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb index 0895d06356..d40e0ef1f0 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb @@ -138,7 +138,7 @@ module ActiveRecord end def encode_range(range) - "[#{type_cast_range_value(range.first)},#{type_cast_range_value(range.last)}#{range.exclude_end? ? ')' : ']'}" + "[#{type_cast_range_value(range.begin)},#{type_cast_range_value(range.end)}#{range.exclude_end? ? ')' : ']'}" end def determine_encoding_of_strings_in_array(value) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb index 946436f7f9..40c5e51d92 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -287,7 +287,7 @@ module ActiveRecord quoted_sequence = quote_table_name(sequence) max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA") if max_pk.nil? - if postgresql_version >= 100000 + if database_version >= 100000 minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA") else minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA") @@ -368,31 +368,6 @@ module ActiveRecord SQL end - def bulk_change_table(table_name, operations) - sql_fragments = [] - non_combinable_operations = [] - - operations.each do |command, args| - table, arguments = args.shift, args - method = :"#{command}_for_alter" - - if respond_to?(method, true) - sqls, procs = Array(send(method, table, *arguments)).partition { |v| v.is_a?(String) } - sql_fragments << sqls - non_combinable_operations.concat(procs) - else - execute "ALTER TABLE #{quote_table_name(table_name)} #{sql_fragments.join(", ")}" unless sql_fragments.empty? - non_combinable_operations.each(&:call) - sql_fragments = [] - non_combinable_operations = [] - send(command, table, *arguments) - end - end - - execute "ALTER TABLE #{quote_table_name(table_name)} #{sql_fragments.join(", ")}" unless sql_fragments.empty? - non_combinable_operations.each(&:call) - end - # Renames a table. # Also renames a table's primary key sequence if the sequence name exists and # matches the Active Record default. @@ -443,14 +418,16 @@ module ActiveRecord end # Adds comment for given table column or drops it if +comment+ is a +nil+ - def change_column_comment(table_name, column_name, comment) # :nodoc: + def change_column_comment(table_name, column_name, comment_or_changes) # :nodoc: clear_cache! + comment = extract_new_comment_value(comment_or_changes) execute "COMMENT ON COLUMN #{quote_table_name(table_name)}.#{quote_column_name(column_name)} IS #{quote(comment)}" end # Adds comment for given table or drops it if +comment+ is a +nil+ - def change_table_comment(table_name, comment) # :nodoc: + def change_table_comment(table_name, comment_or_changes) # :nodoc: clear_cache! + comment = extract_new_comment_value(comment_or_changes) execute "COMMENT ON TABLE #{quote_table_name(table_name)} IS #{quote(comment)}" end @@ -548,21 +525,21 @@ module ActiveRecord # The hard limit is 1GB, because of a 32-bit size field, and TOAST. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "No binary type has byte size #{limit}.") + else raise ArgumentError, "No binary type has byte size #{limit}. The limit on binary can be at most 1GB - 1byte." end when "text" # PostgreSQL doesn't support limits on text columns. # The hard limit is 1GB, according to section 8.3 in the manual. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "The limit on text can be at most 1GB - 1byte.") + else raise ArgumentError, "No text type has byte size #{limit}. The limit on text can be at most 1GB - 1byte." end when "integer" case limit when 1, 2; "smallint" when nil, 3, 4; "integer" when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead.") + else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead." end else super @@ -623,10 +600,10 @@ module ActiveRecord # validate_foreign_key :accounts, name: :special_fk_name # # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. - def validate_foreign_key(from_table, options_or_to_table = {}) + def validate_foreign_key(from_table, to_table = nil, **options) return unless supports_validate_constraints? - fk_name_to_validate = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_validate = foreign_key_for!(from_table, to_table: to_table, **options).name validate_constraint from_table, fk_name_to_validate end @@ -650,16 +627,19 @@ module ActiveRecord default_value = extract_value_from_default(default) default_function = extract_default_function(default_value, default) - PostgreSQLColumn.new( + if match = default_function&.match(/\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z/) + serial = sequence_name_from_parts(table_name, column_name, match[:suffix]) == match[:sequence_name] + end + + PostgreSQL::Column.new( column_name, default_value, type_metadata, !notnull, - table_name, default_function, - collation, + collation: collation, comment: comment.presence, - max_identifier_length: max_identifier_length + serial: serial ) end @@ -672,7 +652,23 @@ module ActiveRecord precision: cast_type.precision, scale: cast_type.scale, ) - PostgreSQLTypeMetadata.new(simple_type, oid: oid, fmod: fmod) + PostgreSQL::TypeMetadata.new(simple_type, oid: oid, fmod: fmod) + end + + def sequence_name_from_parts(table_name, column_name, suffix) + over_length = [table_name, column_name, suffix].sum(&:length) + 2 - max_identifier_length + + if over_length > 0 + column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min + over_length -= column_name.length - column_name_length + column_name = column_name[0, column_name_length - [over_length, 0].min] + end + + if over_length > 0 + table_name = table_name[0, table_name.length - over_length] + end + + "#{table_name}_#{column_name}_#{suffix}" end def extract_foreign_key_action(specifier) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb b/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb index cd69d28139..8bdec623af 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb @@ -3,38 +3,34 @@ module ActiveRecord # :stopdoc: module ConnectionAdapters - class PostgreSQLTypeMetadata < DelegateClass(SqlTypeMetadata) - undef to_yaml if method_defined?(:to_yaml) + module PostgreSQL + class TypeMetadata < DelegateClass(SqlTypeMetadata) + undef to_yaml if method_defined?(:to_yaml) - attr_reader :oid, :fmod, :array + attr_reader :oid, :fmod - def initialize(type_metadata, oid: nil, fmod: nil) - super(type_metadata) - @type_metadata = type_metadata - @oid = oid - @fmod = fmod - @array = /\[\]$/.match?(type_metadata.sql_type) - end - - def sql_type - super.gsub(/\[\]$/, "") - end - - def ==(other) - other.is_a?(PostgreSQLTypeMetadata) && - attributes_for_hash == other.attributes_for_hash - end - alias eql? == - - def hash - attributes_for_hash.hash - end + def initialize(type_metadata, oid: nil, fmod: nil) + super(type_metadata) + @oid = oid + @fmod = fmod + end - protected + def ==(other) + other.is_a?(TypeMetadata) && + __getobj__ == other.__getobj__ && + oid == other.oid && + fmod == other.fmod + end + alias eql? == - def attributes_for_hash - [self.class, @type_metadata, oid, fmod] + def hash + TypeMetadata.hash ^ + __getobj__.hash ^ + oid.hash ^ + fmod.hash end + end end + PostgreSQLTypeMetadata = PostgreSQL::TypeMetadata end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 95beeb4cae..91318a0af1 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -196,6 +196,17 @@ module ActiveRecord true end + def supports_insert_returning? + true + end + + def supports_insert_on_conflict? + database_version >= 90500 + end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? + def index_algorithms { concurrently: "CONCURRENTLY" } end @@ -236,15 +247,10 @@ module ActiveRecord # @local_tz is initialized as nil to avoid warnings when connect tries to use it @local_tz = nil - @default_timezone = nil - @timestamp_decoder = nil @max_identifier_length = nil configure_connection add_pg_encoders - @statements = StatementPool.new @connection, - self.class.type_cast_config_to_integer(config[:statement_limit]) - add_pg_decoders @type_map = Type::HashLookupTypeMap.new @@ -253,17 +259,6 @@ module ActiveRecord @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true end - # Clears the prepared statements cache. - def clear_cache! - @lock.synchronize do - @statements.clear - end - end - - def truncate(table_name, name = nil) - exec_query "TRUNCATE TABLE #{quote_table_name(table_name)}", name, [] - end - # Is this connection alive and ready for queries? def active? @lock.synchronize do @@ -280,6 +275,8 @@ module ActiveRecord super @connection.reset configure_connection + rescue PG::ConnectionBad + connect end end @@ -347,7 +344,14 @@ module ActiveRecord end def supports_pgcrypto_uuid? - postgresql_version >= 90400 + database_version >= 90400 + end + + def supports_optimizer_hints? + unless defined?(@has_pg_hint_plan) + @has_pg_hint_plan = extension_available?("pg_hint_plan") + end + @has_pg_hint_plan end def supports_lazy_transactions? @@ -380,9 +384,12 @@ module ActiveRecord } end + def extension_available?(name) + query_value("SELECT true FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") + end + def extension_enabled?(name) - res = exec_query("SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", "SCHEMA") - res.cast_values.first + query_value("SELECT installed_version IS NOT NULL FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") end def extensions @@ -393,8 +400,6 @@ module ActiveRecord def max_identifier_length @max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i end - alias table_alias_length max_identifier_length - alias index_name_length max_identifier_length # Set the authorized user for this session def session_auth=(user) @@ -417,21 +422,37 @@ module ActiveRecord } # Returns the version of the connected PostgreSQL server. - def postgresql_version + def get_database_version # :nodoc: @connection.server_version end + alias :postgresql_version :database_version def default_index_type?(index) # :nodoc: index.using == :btree || super end - private - def check_version - if postgresql_version < 90300 - raise "Your version of PostgreSQL (#{postgresql_version}) is too old. Active Record supports PostgreSQL >= 9.3." - end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") end + sql << " RETURNING #{insert.returning}" if insert.returning + sql + end + + def check_version # :nodoc: + if database_version < 90300 + raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3." + end + end + + private + # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" NUMERIC_VALUE_OUT_OF_RANGE = "22003" @@ -723,6 +744,8 @@ module ActiveRecord def connect @connection = PG.connect(@connection_parameters) configure_connection + add_pg_encoders + add_pg_decoders end # Configures the encoding, verbosity, schema search path, and time zone of the connection. @@ -803,6 +826,10 @@ module ActiveRecord Arel::Visitors::PostgreSQL.new(self) end + def build_statement_pool + StatementPool.new(@connection, self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + def can_perform_case_insensitive_comparison_for?(column) @case_insensitive_cache ||= {} @case_insensitive_cache[column.sql_type] ||= begin @@ -846,6 +873,9 @@ module ActiveRecord end def add_pg_decoders + @default_timezone = nil + @timestamp_decoder = nil + coders_by_name = { "int2" => PG::TextDecoder::Integer, "int4" => PG::TextDecoder::Integer, diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index c10765f42d..dbfe1e4a34 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -13,6 +13,7 @@ module ActiveRecord @columns_hash = {} @primary_keys = {} @data_sources = {} + @indexes = {} end def initialize_dup(other) @@ -21,22 +22,27 @@ module ActiveRecord @columns_hash = @columns_hash.dup @primary_keys = @primary_keys.dup @data_sources = @data_sources.dup + @indexes = @indexes.dup end def encode_with(coder) - coder["columns"] = @columns - coder["columns_hash"] = @columns_hash - coder["primary_keys"] = @primary_keys - coder["data_sources"] = @data_sources - coder["version"] = connection.migration_context.current_version + coder["columns"] = @columns + coder["columns_hash"] = @columns_hash + coder["primary_keys"] = @primary_keys + coder["data_sources"] = @data_sources + coder["indexes"] = @indexes + coder["version"] = connection.migration_context.current_version + coder["database_version"] = database_version end def init_with(coder) - @columns = coder["columns"] - @columns_hash = coder["columns_hash"] - @primary_keys = coder["primary_keys"] - @data_sources = coder["data_sources"] - @version = coder["version"] + @columns = coder["columns"] + @columns_hash = coder["columns_hash"] + @primary_keys = coder["primary_keys"] + @data_sources = coder["data_sources"] + @indexes = coder["indexes"] || {} + @version = coder["version"] + @database_version = coder["database_version"] end def primary_keys(table_name) @@ -57,6 +63,7 @@ module ActiveRecord primary_keys(table_name) columns(table_name) columns_hash(table_name) + indexes(table_name) end end @@ -82,17 +89,27 @@ module ActiveRecord @columns_hash.key?(table_name) end + def indexes(table_name) + @indexes[table_name] ||= connection.indexes(table_name) + end + + def database_version # :nodoc: + @database_version ||= connection.get_database_version + end + # Clears out internal caches def clear! @columns.clear @columns_hash.clear @primary_keys.clear @data_sources.clear + @indexes.clear @version = nil + @database_version = nil end def size - [@columns, @columns_hash, @primary_keys, @data_sources].map(&:size).inject :+ + [@columns, @columns_hash, @primary_keys, @data_sources].sum(&:size) end # Clear out internal caches for the data source +name+. @@ -101,20 +118,21 @@ module ActiveRecord @columns_hash.delete name @primary_keys.delete name @data_sources.delete name + @indexes.delete name end def marshal_dump # if we get current version during initialization, it happens stack over flow. @version = connection.migration_context.current_version - [@version, @columns, @columns_hash, @primary_keys, @data_sources] + [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, database_version] end def marshal_load(array) - @version, @columns, @columns_hash, @primary_keys, @data_sources = array + @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, @database_version = array + @indexes = @indexes || {} end private - def prepare_data_sources connection.data_sources.each { |source| @data_sources[source] = true } end diff --git a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb index 8489bcbf1d..df28df7a7c 100644 --- a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb @@ -16,19 +16,22 @@ module ActiveRecord def ==(other) other.is_a?(SqlTypeMetadata) && - attributes_for_hash == other.attributes_for_hash + sql_type == other.sql_type && + type == other.type && + limit == other.limit && + precision == other.precision && + scale == other.scale end alias eql? == def hash - attributes_for_hash.hash + SqlTypeMetadata.hash ^ + sql_type.hash ^ + type.hash ^ + limit.hash ^ + precision.hash >> 1 ^ + scale.hash >> 2 end - - protected - - def attributes_for_hash - [self.class, sql_type, type, limit, precision, scale] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb new file mode 100644 index 0000000000..73b80e8c15 --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module DatabaseStatements + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :pragma, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + + def execute(sql, name = nil) #:nodoc: + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute(sql) + end + end + end + + def exec_query(sql, name = nil, binds = [], prepare: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + type_casted_binds = type_casted_binds(binds) + + log(sql, name, binds, type_casted_binds) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + # Don't cache statements if they are not prepared + unless prepare + stmt = @connection.prepare(sql) + begin + cols = stmt.columns + unless without_prepared_statement?(binds) + stmt.bind_params(type_casted_binds) + end + records = stmt.to_a + ensure + stmt.close + end + else + stmt = @statements[sql] ||= @connection.prepare(sql) + cols = stmt.columns + stmt.reset! + stmt.bind_params(type_casted_binds) + records = stmt.to_a + end + + ActiveRecord::Result.new(cols, records) + end + end + end + + def exec_delete(sql, name = "SQL", binds = []) + exec_query(sql, name, binds) + @connection.changes + end + alias :exec_update :exec_delete + + def begin_db_transaction #:nodoc: + log("begin transaction", "TRANSACTION") { @connection.transaction } + end + + def commit_db_transaction #:nodoc: + log("commit transaction", "TRANSACTION") { @connection.commit } + end + + def exec_rollback_db_transaction #:nodoc: + log("rollback transaction", "TRANSACTION") { @connection.rollback } + end + + + private + def execute_batch(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute_batch2(sql) + end + end + end + + def last_inserted_id(result) + @connection.last_insert_row_id + end + + def build_fixture_statements(fixture_set) + fixture_set.flat_map do |table_name, fixtures| + next if fixtures.empty? + fixtures.map { |fixture| build_fixture_sql([fixture], table_name) } + end.compact + end + + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "DELETE FROM #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb index 2394982a7d..e48f59b4f0 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -52,6 +52,32 @@ module ActiveRecord end.compact end + def add_foreign_key(from_table, to_table, **options) + alter_table(from_table) do |definition| + to_table = strip_table_name_prefix_and_suffix(to_table) + definition.foreign_key(to_table, options) + end + end + + def remove_foreign_key(from_table, to_table = nil, **options) + to_table ||= options[:to_table] + options = options.except(:name, :to_table) + foreign_keys = foreign_keys(from_table) + + fkey = foreign_keys.detect do |fk| + table = to_table || begin + table = options[:column].to_s.delete_suffix("_id") + Base.pluralize_table_names ? table.pluralize : table + end + table = strip_table_name_prefix_and_suffix(table) + fk_to_table = strip_table_name_prefix_and_suffix(fk.to_table) + fk_to_table == table && options.all? { |k, v| fk.options[k].to_s == v.to_s } + end || raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") + + foreign_keys.delete(fkey) + alter_table(from_table, foreign_keys) + end + def create_schema_dumper(options) SQLite3::SchemaDumper.create(self, options) end @@ -79,7 +105,7 @@ module ActiveRecord end type_metadata = fetch_type_metadata(field["type"]) - Column.new(field["name"], default, type_metadata, field["notnull"].to_i == 0, table_name, nil, field["collation"]) + Column.new(field["name"], default, type_metadata, field["notnull"].to_i == 0, collation: field["collation"]) end def data_source_sql(name = nil, type: nil) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 7b3630662b..f5f5827d04 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -4,12 +4,13 @@ require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/sqlite3/explain_pretty_printer" require "active_record/connection_adapters/sqlite3/quoting" +require "active_record/connection_adapters/sqlite3/database_statements" require "active_record/connection_adapters/sqlite3/schema_creation" require "active_record/connection_adapters/sqlite3/schema_definitions" require "active_record/connection_adapters/sqlite3/schema_dumper" require "active_record/connection_adapters/sqlite3/schema_statements" -gem "sqlite3", "~> 1.3", ">= 1.3.6" +gem "sqlite3", "~> 1.4" require "sqlite3" module ActiveRecord @@ -36,8 +37,6 @@ module ActiveRecord config.merge(results_as_hash: true) ) - db.busy_timeout(ConnectionAdapters::SQLite3Adapter.type_cast_config_to_integer(config[:timeout])) if config[:timeout] - ConnectionAdapters::SQLite3Adapter.new(db, logger, nil, config) rescue Errno::ENOENT => error if error.message.include?("No such file or directory") @@ -60,6 +59,7 @@ module ActiveRecord include SQLite3::Quoting include SQLite3::SchemaStatements + include SQLite3::DatabaseStatements NATIVE_DATABASE_TYPES = { primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL", @@ -95,9 +95,6 @@ module ActiveRecord def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @active = true - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) configure_connection end @@ -114,14 +111,14 @@ module ActiveRecord end def supports_expression_index? - sqlite_version >= "3.9.0" + database_version >= "3.9.0" end def requires_reloading? true end - def supports_foreign_keys_in_create? + def supports_foreign_keys? true end @@ -137,23 +134,29 @@ module ActiveRecord true end + def supports_insert_on_conflict? + database_version >= "3.24.0" + end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? + def active? - @active + !@connection.closed? + end + + def reconnect! + super + connect if @connection.closed? end # Disconnects from the database if already connected. Otherwise, this # method does nothing. def disconnect! super - @active = false @connection.close rescue nil end - # Clears the prepared statements cache. - def clear_cache! - @statements.clear - end - def supports_index_sort_order? true end @@ -201,91 +204,11 @@ module ActiveRecord #-- # DATABASE STATEMENTS ====================================== #++ - - READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :pragma, :release, :savepoint, :rollback) # :nodoc: - private_constant :READ_QUERY - - def write_query?(sql) # :nodoc: - !READ_QUERY.match?(sql) - end - def explain(arel, binds = []) sql = "EXPLAIN QUERY PLAN #{to_sql(arel, binds)}" SQLite3::ExplainPrettyPrinter.new.pp(exec_query(sql, "EXPLAIN", [])) end - def exec_query(sql, name = nil, binds = [], prepare: false) - if preventing_writes? && write_query?(sql) - raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" - end - - materialize_transactions - - type_casted_binds = type_casted_binds(binds) - - log(sql, name, binds, type_casted_binds) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - # Don't cache statements if they are not prepared - unless prepare - stmt = @connection.prepare(sql) - begin - cols = stmt.columns - unless without_prepared_statement?(binds) - stmt.bind_params(type_casted_binds) - end - records = stmt.to_a - ensure - stmt.close - end - else - stmt = @statements[sql] ||= @connection.prepare(sql) - cols = stmt.columns - stmt.reset! - stmt.bind_params(type_casted_binds) - records = stmt.to_a - end - - ActiveRecord::Result.new(cols, records) - end - end - end - - def exec_delete(sql, name = "SQL", binds = []) - exec_query(sql, name, binds) - @connection.changes - end - alias :exec_update :exec_delete - - def last_inserted_id(result) - @connection.last_insert_row_id - end - - def execute(sql, name = nil) #:nodoc: - if preventing_writes? && write_query?(sql) - raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" - end - - materialize_transactions - - log(sql, name) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.execute(sql) - end - end - end - - def begin_db_transaction #:nodoc: - log("begin transaction", nil) { @connection.transaction } - end - - def commit_db_transaction #:nodoc: - log("commit transaction", nil) { @connection.commit } - end - - def exec_rollback_db_transaction #:nodoc: - log("rollback transaction", nil) { @connection.rollback } - end - # SCHEMA STATEMENTS ======================================== def primary_keys(table_name) # :nodoc: @@ -320,6 +243,9 @@ module ActiveRecord def remove_column(table_name, column_name, type = nil, options = {}) #:nodoc: alter_table(table_name) do |definition| definition.remove_column column_name + definition.foreign_keys.delete_if do |_, fk_options| + fk_options[:column] == column_name.to_s + end end end @@ -378,15 +304,26 @@ module ActiveRecord end end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - disable_referential_integrity do - transaction(requires_new: true) do - tables_to_delete.each { |table| delete "DELETE FROM #{quote_table_name(table)}", "Fixture Delete" } + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" - fixture_set.each do |table_name, rows| - rows.each { |row| insert_fixture(row, table_name) } - end - end + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") + end + + sql + end + + def get_database_version # :nodoc: + SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) + end + + def check_version # :nodoc: + if database_version < "3.8.0" + raise "Your version of SQLite (#{database_version}) is too old. Active Record supports SQLite >= 3.8." end end @@ -397,12 +334,6 @@ module ActiveRecord 999 end - def check_version - if sqlite_version < "3.8.0" - raise "Your version of SQLite (#{sqlite_version}) is too old. Active Record supports SQLite >= 3.8." - end - end - def initialize_type_map(m = type_map) super register_class_with_limit m, %r(int)i, SQLite3Integer @@ -421,9 +352,8 @@ module ActiveRecord type.to_sym == :primary_key || options[:primary_key] end - def alter_table(table_name, options = {}) + def alter_table(table_name, foreign_keys = foreign_keys(table_name), **options) altered_table_name = "a#{table_name}" - foreign_keys = foreign_keys(table_name) caller = lambda do |definition| rename = options[:rename] || {} @@ -431,7 +361,8 @@ module ActiveRecord if column = rename[fk.options[:column]] fk.options[:column] = column end - definition.foreign_key(fk.to_table, fk.options) + to_table = strip_table_name_prefix_and_suffix(fk.to_table) + definition.foreign_key(to_table, fk.options) end yield definition if block_given? @@ -520,10 +451,6 @@ module ActiveRecord SELECT #{quoted_from_columns} FROM #{quote_table_name(from)}") end - def sqlite_version - @sqlite_version ||= SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) - end - def translate_exception(exception, message:, sql:, binds:) case exception.message # SQLite 3.8.2 returns a newly formatted error message: @@ -586,7 +513,21 @@ module ActiveRecord Arel::Visitors::SQLite.new(self) end + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + + def connect + @connection = ::SQLite3::Database.new( + @config[:database].to_s, + @config.merge(results_as_hash: true) + ) + configure_connection + end + def configure_connection + @connection.busy_timeout(self.class.type_cast_config_to_integer(@config[:timeout])) if @config[:timeout] + execute("PRAGMA foreign_keys = ON", "SCHEMA") end diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index 53069cd899..040ebdb960 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -85,14 +85,14 @@ module ActiveRecord # based on the requested role: # # ActiveRecord::Base.connected_to(role: :writing) do - # Dog.create! # creates dog using dog connection + # Dog.create! # creates dog using dog writing connection # end # # ActiveRecord::Base.connected_to(role: :reading) do # Dog.create! # throws exception because we're on a replica # end # - # ActiveRecord::Base.connected_to(role: :unknown_ode) do + # ActiveRecord::Base.connected_to(role: :unknown_role) do # # raises exception due to non-existent role # end # @@ -100,11 +100,20 @@ module ActiveRecord # you can use +connected_to+ with a +database+ argument. The +database+ argument # expects a symbol that corresponds to the database key in your config. # - # This will connect to a new database for the queries inside the block. - # # ActiveRecord::Base.connected_to(database: :animals_slow_replica) do # Dog.run_a_long_query # runs a long query while connected to the +animals_slow_replica+ # end + # + # This will connect to a new database for the queries inside the block. By + # default the `:writing` role will be used since all connections must be assigned + # a role. If you would like to use a different role you can pass a hash to database: + # + # ActiveRecord::Base.connected_to(database: { readonly_slow: :animals_slow_replica }) do + # # runs a long query while connected to the +animals_slow_replica+ using the readonly_slow role. + # Dog.run_a_long_query + # end + # + # When using the database key a new connection will be established every time. def connected_to(database: nil, role: nil, &blk) if database && role raise ArgumentError, "connected_to can only accept a `database` or a `role` argument, but not both arguments." @@ -112,17 +121,14 @@ module ActiveRecord if database.is_a?(Hash) role, database = database.first role = role.to_sym - else - role = database.to_sym end config_hash = resolve_config_for_connection(database) handler = lookup_connection_handler(role) - with_handler(role) do - handler.establish_connection(config_hash) - yield - end + handler.establish_connection(config_hash) + + with_handler(role, &blk) elsif role with_handler(role.to_sym, &blk) else @@ -154,6 +160,7 @@ module ActiveRecord end def lookup_connection_handler(handler_key) # :nodoc: + handler_key ||= ActiveRecord::Base.writing_role connection_handlers[handler_key] ||= ActiveRecord::ConnectionAdapters::ConnectionHandler.new end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index c67980173f..dfd33d3dd7 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -101,7 +101,6 @@ module ActiveRecord # environment where dumping schema is rarely needed. mattr_accessor :dump_schema_after_migration, instance_writer: false, default: true - mattr_accessor :database_selector, instance_writer: false ## # :singleton-method: # Specifies which database schemas to dump when calling db:structure:dump. @@ -161,7 +160,7 @@ module ActiveRecord return super if block_given? || primary_key.nil? || scope_attributes? || - columns_hash.include?(inheritance_column) + columns_hash.key?(inheritance_column) && !base_class? id = ids.first @@ -175,14 +174,14 @@ module ActiveRecord record = statement.execute([id], connection)&.first unless record - raise RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id}", - name, primary_key, id) + raise RecordNotFound.new("Couldn't find #{name} with '#{key}'=#{id}", name, key, id) end record end def find_by(*args) # :nodoc: - return super if scope_attributes? || reflect_on_all_aggregations.any? + return super if scope_attributes? || reflect_on_all_aggregations.any? || + columns_hash.key?(inheritance_column) && !base_class? hash = args.first @@ -269,7 +268,8 @@ module ActiveRecord end def arel_attribute(name, table = arel_table) # :nodoc: - name = attribute_alias(name) if attribute_alias?(name) + name = name.to_s + name = attribute_aliases[name] || name table[name] end @@ -317,7 +317,7 @@ module ActiveRecord # # Instantiates a single new object # User.new(first_name: 'Jamie') def initialize(attributes = nil) - self.class.define_attribute_methods + @new_record = true @attributes = self.class._default_attributes.deep_dup init_internals @@ -354,12 +354,10 @@ module ActiveRecord # +attributes+ should be an attributes object, and unlike the # `initialize` method, no assignment calls are made per attribute. def init_with_attributes(attributes, new_record = false) # :nodoc: - init_internals - @new_record = new_record @attributes = attributes - self.class.define_attribute_methods + init_internals yield self if block_given? @@ -398,13 +396,13 @@ module ActiveRecord ## def initialize_dup(other) # :nodoc: @attributes = @attributes.deep_dup - @attributes.reset(self.class.primary_key) + @attributes.reset(@primary_key) _run_initialize_callbacks @new_record = true @destroyed = false - @_start_transaction_state = {} + @_start_transaction_state = nil @transaction_state = nil super @@ -465,6 +463,7 @@ module ActiveRecord # Returns +true+ if the attributes hash has been frozen. def frozen? + sync_with_transaction_state if @transaction_state&.finalized? @attributes.frozen? end @@ -569,22 +568,18 @@ module ActiveRecord end def init_internals + @primary_key = self.class.primary_key @readonly = false @destroyed = false @marked_for_destruction = false @destroyed_by_association = nil - @new_record = true - @_start_transaction_state = {} + @_start_transaction_state = nil @transaction_state = nil - end - def initialize_internals_callback + self.class.define_attribute_methods end - def thaw - if frozen? - @attributes = @attributes.dup - end + def initialize_internals_callback end def custom_inspect_method_defined? diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index 73adf66684..44b5cfc738 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -7,7 +7,7 @@ require "active_record/database_configurations/url_config" module ActiveRecord # ActiveRecord::DatabaseConfigurations returns an array of DatabaseConfig # objects (either a HashConfig or UrlConfig) that are constructed from the - # application's database configuration hash or url string. + # application's database configuration hash or URL string. class DatabaseConfigurations attr_reader :configurations delegate :any?, to: :configurations @@ -17,22 +17,22 @@ module ActiveRecord end # Collects the configs for the environment and optionally the specification - # name passed in. To include replica configurations pass `include_replicas: true`. + # name passed in. To include replica configurations pass <tt>include_replicas: true</tt>. # # If a spec name is provided a single DatabaseConfig object will be # returned, otherwise an array of DatabaseConfig objects will be # returned that corresponds with the environment and type requested. # - # Options: + # ==== Options # - # <tt>env_name:</tt> The environment name. Defaults to nil which will collect - # configs for all environments. - # <tt>spec_name:</tt> The specification name (ie primary, animals, etc.). Defaults - # to +nil+. - # <tt>include_replicas:</tt> Determines whether to include replicas in - # the returned list. Most of the time we're only iterating over the write - # connection (i.e. migrations don't need to run for the write and read connection). - # Defaults to +false+. + # * <tt>env_name:</tt> The environment name. Defaults to +nil+ which will collect + # configs for all environments. + # * <tt>spec_name:</tt> The specification name (i.e. primary, animals, etc.). Defaults + # to +nil+. + # * <tt>include_replicas:</tt> Determines whether to include replicas in + # the returned list. Most of the time we're only iterating over the write + # connection (i.e. migrations don't need to run for the write and read connection). + # Defaults to +false+. def configs_for(env_name: nil, spec_name: nil, include_replicas: false) configs = env_with_configs(env_name) @@ -53,7 +53,7 @@ module ActiveRecord # Returns the config hash that corresponds with the environment # - # If the application has multiple databases `default_hash` will + # If the application has multiple databases +default_hash+ will # return the first config hash for the environment. # # { database: "my_db", adapter: "mysql2" } @@ -65,7 +65,7 @@ module ActiveRecord # Returns a single DatabaseConfig object based on the requested environment. # - # If the application has multiple databases `find_db_config` will return + # If the application has multiple databases +find_db_config+ will return # the first DatabaseConfig for the environment. def find_db_config(env) configurations.find do |db_config| @@ -102,10 +102,11 @@ module ActiveRecord def build_configs(configs) return configs.configurations if configs.is_a?(DatabaseConfigurations) + return configs if configs.is_a?(Array) build_db_config = configs.each_pair.flat_map do |env_name, config| walk_configs(env_name.to_s, "primary", config) - end.compact + end.flatten.compact if url = ENV["DATABASE_URL"] build_url_config(url, build_db_config) @@ -157,7 +158,7 @@ module ActiveRecord configs else configs.map do |config| - ActiveRecord::DatabaseConfigurations::UrlConfig.new(env, config.spec_name, url, config.config) + ActiveRecord::DatabaseConfigurations::UrlConfig.new(config.env_name, config.spec_name, url, config.config) end end else @@ -166,21 +167,38 @@ module ActiveRecord end def method_missing(method, *args, &blk) - if Hash.method_defined?(method) - ActiveSupport::Deprecation.warn \ - "Returning a hash from ActiveRecord::Base.configurations is deprecated. Therefore calling `#{method}` on the hash is also deprecated. Please switch to using the `configs_for` method instead to collect and iterate over database configurations." - end - case method when :each, :first + throw_getter_deprecation(method) configurations.send(method, *args, &blk) when :fetch + throw_getter_deprecation(method) configs_for(env_name: args.first) when :values + throw_getter_deprecation(method) configurations.map(&:config) + when :[]= + throw_setter_deprecation(method) + + env_name = args[0] + config = args[1] + + remaining_configs = configurations.reject { |db_config| db_config.env_name == env_name } + new_config = build_configs(env_name => config) + new_configs = remaining_configs + new_config + + ActiveRecord::Base.configurations = new_configs else - super + raise NotImplementedError, "`ActiveRecord::Base.configurations` in Rails 6 now returns an object instead of a hash. The `#{method}` method is not supported. Please use `configs_for` or consult the documentation for supported methods." end end + + def throw_setter_deprecation(method) + ActiveSupport::Deprecation.warn("Setting `ActiveRecord::Base.configurations` with `#{method}` is deprecated. Use `ActiveRecord::Base.configurations=` directly to set the configurations instead.") + end + + def throw_getter_deprecation(method) + ActiveSupport::Deprecation.warn("`ActiveRecord::Base.configurations` no longer returns a hash. Methods that act on the hash like `#{method}` are deprecated and will be removed in Rails 6.1. Use the `configs_for` method to collect and iterate over the database configurations.") + end end end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index c176a62458..e31ff09391 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -14,16 +14,16 @@ module ActiveRecord # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 # @env_name="development", @spec_name="primary", @config={"database"=>"db_name"}> # - # Options are: + # ==== Options # - # <tt>:env_name</tt> - The Rails environment, ie "development" - # <tt>:spec_name</tt> - The specification name. In a standard two-tier - # database configuration this will default to "primary". In a multiple - # database three-tier database configuration this corresponds to the name - # used in the second tier, for example "primary_readonly". - # <tt>:config</tt> - The config hash. This is the hash that contains the - # database adapter, name, and other important information for database - # connections. + # * <tt>:env_name</tt> - The Rails environment, i.e. "development". + # * <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # * <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. class HashConfig < DatabaseConfig attr_reader :config @@ -33,14 +33,14 @@ module ActiveRecord end # Determines whether a database configuration is for a replica / readonly - # connection. If the `replica` key is present in the config, `replica?` will + # connection. If the +replica+ key is present in the config, +replica?+ will # return +true+. def replica? config["replica"] end # The migrations paths for a database configuration. If the - # `migrations_paths` key is present in the config, `migrations_paths` + # +migrations_paths+ key is present in the config, +migrations_paths+ # will return its value. def migrations_paths config["migrations_paths"] diff --git a/activerecord/lib/active_record/database_configurations/url_config.rb b/activerecord/lib/active_record/database_configurations/url_config.rb index 8e8aa69478..e2d30ae416 100644 --- a/activerecord/lib/active_record/database_configurations/url_config.rb +++ b/activerecord/lib/active_record/database_configurations/url_config.rb @@ -17,17 +17,17 @@ module ActiveRecord # @config={"adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost"}, # @url="postgres://localhost/foo"> # - # Options are: + # ==== Options # - # <tt>:env_name</tt> - The Rails environment, ie "development" - # <tt>:spec_name</tt> - The specification name. In a standard two-tier - # database configuration this will default to "primary". In a multiple - # database three-tier database configuration this corresponds to the name - # used in the second tier, for example "primary_readonly". - # <tt>:url</tt> - The database URL. - # <tt>:config</tt> - The config hash. This is the hash that contains the - # database adapter, name, and other important information for database - # connections. + # * <tt>:env_name</tt> - The Rails environment, ie "development". + # * <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # * <tt>:url</tt> - The database URL. + # * <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. class UrlConfig < DatabaseConfig attr_reader :url, :config @@ -42,14 +42,14 @@ module ActiveRecord end # Determines whether a database configuration is for a replica / readonly - # connection. If the `replica` key is present in the config, `replica?` will + # connection. If the +replica+ key is present in the config, +replica?+ will # return +true+. def replica? config["replica"] end # The migrations paths for a database configuration. If the - # `migrations_paths` key is present in the config, `migrations_paths` + # +migrations_paths+ key is present in the config, +migrations_paths+ # will return its value. def migrations_paths config["migrations_paths"] diff --git a/activerecord/lib/active_record/dynamic_matchers.rb b/activerecord/lib/active_record/dynamic_matchers.rb index 3bb8c6f4e3..398a029068 100644 --- a/activerecord/lib/active_record/dynamic_matchers.rb +++ b/activerecord/lib/active_record/dynamic_matchers.rb @@ -49,11 +49,11 @@ module ActiveRecord attr_reader :model, :name, :attribute_names - def initialize(model, name) + def initialize(model, method_name) @model = model - @name = name.to_s + @name = method_name.to_s @attribute_names = @name.match(self.class.pattern)[1].split("_and_") - @attribute_names.map! { |n| @model.attribute_aliases[n] || n } + @attribute_names.map! { |name| @model.attribute_aliases[name] || name } end def valid? diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb index e6dba66a08..8077630aeb 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -31,7 +31,9 @@ module ActiveRecord # as well. With the above example: # # Conversation.active + # Conversation.not_active # Conversation.archived + # Conversation.not_archived # # Of course, you can also query them directly if the scopes don't fit your # needs: @@ -196,9 +198,13 @@ module ActiveRecord define_method("#{value_method_name}!") { update!(attr => value) } # scope :active, -> { where(status: 0) } + # scope :not_active, -> { where.not(status: 0) } if enum_scopes != false klass.send(:detect_enum_conflict!, name, value_method_name, true) klass.scope value_method_name, -> { where(attr => value) } + + klass.send(:detect_enum_conflict!, name, "not_#{value_method_name}", true) + klass.scope "not_#{value_method_name}", -> { where.not(attr => value) } end end end diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 0858af3874..60cf9818c1 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -68,7 +68,7 @@ module ActiveRecord # Raised by {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] and # {ActiveRecord::Base.create!}[rdoc-ref:Persistence::ClassMethods#create!] - # methods when a record is invalid and can not be saved. + # methods when a record is invalid and cannot be saved. class RecordNotSaved < ActiveRecordError attr_reader :record @@ -126,16 +126,26 @@ module ActiveRecord # Raised when a foreign key constraint cannot be added because the column type does not match the referenced column type. class MismatchedForeignKey < StatementInvalid - def initialize(adapter = nil, message: nil, sql: nil, binds: nil, table: nil, foreign_key: nil, target_table: nil, primary_key: nil) - @adapter = adapter + def initialize( + message: nil, + sql: nil, + binds: nil, + table: nil, + foreign_key: nil, + target_table: nil, + primary_key: nil, + primary_key_column: nil + ) if table - msg = +<<~EOM - Column `#{foreign_key}` on table `#{table}` has a type of `#{column_type(table, foreign_key)}`. - This does not match column `#{primary_key}` on `#{target_table}`, which has type `#{column_type(target_table, primary_key)}`. - To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :integer. (For example `t.integer #{foreign_key}`). + type = primary_key_column.bigint? ? :bigint : primary_key_column.type + msg = <<~EOM.squish + Column `#{foreign_key}` on table `#{table}` does not match column `#{primary_key}` on `#{target_table}`, + which has type `#{primary_key_column.sql_type}`. + To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :#{type}. + (For example `t.#{type} :#{foreign_key}`). EOM else - msg = +<<~EOM + msg = <<~EOM.squish There is a mismatch between the foreign key and primary key column types. Verify that the foreign key column type and the primary key of the associated table match types. EOM @@ -145,11 +155,6 @@ module ActiveRecord end super(msg, sql: sql, binds: binds) end - - private - def column_type(table, column) - @adapter.columns(table).detect { |c| c.name == column }.sql_type - end end # Raised when a record cannot be inserted or updated because it would violate a not null constraint. diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 16dff9c2b0..7f92174f87 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -8,9 +8,9 @@ module ActiveRecord module VERSION MAJOR = 6 - MINOR = 0 + MINOR = 1 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/inheritance.rb b/activerecord/lib/active_record/inheritance.rb index 138fd1cf53..9570bc6f86 100644 --- a/activerecord/lib/active_record/inheritance.rb +++ b/activerecord/lib/active_record/inheritance.rb @@ -249,7 +249,7 @@ module ActiveRecord sti_column = arel_attribute(inheritance_column, table) sti_names = ([self] + descendants).map(&:sti_name) - sti_column.in(sti_names) + predicate_builder.build(sti_column, sti_names) end # Detect the subclass from the inheritance column of attrs. If the inheritance column value diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb new file mode 100644 index 0000000000..f6577dcbc4 --- /dev/null +++ b/activerecord/lib/active_record/insert_all.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +module ActiveRecord + class InsertAll # :nodoc: + attr_reader :model, :connection, :inserts, :keys + attr_reader :on_duplicate, :returning, :unique_by + + def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil) + raise ArgumentError, "Empty list of attributes passed" if inserts.blank? + + @model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s).to_set + @on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by + + @returning = (connection.supports_insert_returning? ? primary_keys : false) if @returning.nil? + @returning = false if @returning == [] + + @unique_by = find_unique_index_for(unique_by) if unique_by + @on_duplicate = :skip if @on_duplicate == :update && updatable_columns.empty? + + ensure_valid_options_for_connection! + end + + def execute + message = +"#{model} " + message << "Bulk " if inserts.many? + message << (on_duplicate == :update ? "Upsert" : "Insert") + connection.exec_query to_sql, message + end + + def updatable_columns + keys - readonly_columns - unique_by_columns + end + + def primary_keys + Array(model.primary_key) + end + + + def skip_duplicates? + on_duplicate == :skip + end + + def update_duplicates? + on_duplicate == :update + end + + def map_key_with_value + inserts.map do |attributes| + attributes = attributes.stringify_keys + verify_attributes(attributes) + + keys.map do |key| + yield key, attributes[key] + end + end + end + + private + def find_unique_index_for(unique_by) + match = Array(unique_by).map(&:to_s) + + if index = unique_indexes.find { |i| match.include?(i.name) || i.columns == match } + index + else + raise ArgumentError, "No unique index found for #{unique_by}" + end + end + + def unique_indexes + connection.schema_cache.indexes(model.table_name).select(&:unique) + end + + + def ensure_valid_options_for_connection! + if returning && !connection.supports_insert_returning? + raise ArgumentError, "#{connection.class} does not support :returning" + end + + if skip_duplicates? && !connection.supports_insert_on_duplicate_skip? + raise ArgumentError, "#{connection.class} does not support skipping duplicates" + end + + if update_duplicates? && !connection.supports_insert_on_duplicate_update? + raise ArgumentError, "#{connection.class} does not support upsert" + end + + if unique_by && !connection.supports_insert_conflict_target? + raise ArgumentError, "#{connection.class} does not support :unique_by" + end + end + + + def to_sql + connection.build_insert_sql(ActiveRecord::InsertAll::Builder.new(self)) + end + + + def readonly_columns + primary_keys + model.readonly_attributes.to_a + end + + def unique_by_columns + Array(unique_by&.columns) + end + + + def verify_attributes(attributes) + if keys != attributes.keys.to_set + raise ArgumentError, "All objects being inserted must have the same keys" + end + end + + + class Builder + attr_reader :model + + delegate :skip_duplicates?, :update_duplicates?, :keys, to: :insert_all + + def initialize(insert_all) + @insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection + end + + def into + "INTO #{model.quoted_table_name}(#{columns_list})" + end + + def values_list + types = extract_types_from_columns_on(model.table_name, keys: keys) + + values_list = insert_all.map_key_with_value do |key, value| + connection.with_yaml_fallback(types[key].serialize(value)) + end + + Arel::InsertManager.new.create_values_list(values_list).to_sql + end + + def returning + format_columns(insert_all.returning) if insert_all.returning + end + + def conflict_target + if index = insert_all.unique_by + sql = +"(#{format_columns(index.columns)})" + sql << " WHERE #{index.where}" if index.where + sql + elsif update_duplicates? + "(#{format_columns(insert_all.primary_keys)})" + end + end + + def updatable_columns + quote_columns(insert_all.updatable_columns) + end + + private + attr_reader :connection, :insert_all + + def columns_list + format_columns(insert_all.keys) + end + + def extract_types_from_columns_on(table_name, keys:) + columns = connection.schema_cache.columns_hash(table_name) + + unknown_column = (keys - columns.keys).first + raise UnknownAttributeError.new(model.new, unknown_column) if unknown_column + + keys.map { |key| [ key, connection.lookup_cast_type_from_column(columns[key]) ] }.to_h + end + + def format_columns(columns) + quote_columns(columns).join(",") + end + + def quote_columns(columns) + columns.map(&connection.method(:quote_column_name)) + end + end + end +end diff --git a/activerecord/lib/active_record/integration.rb b/activerecord/lib/active_record/integration.rb index fa6f0d36ec..573a823dbc 100644 --- a/activerecord/lib/active_record/integration.rb +++ b/activerecord/lib/active_record/integration.rb @@ -22,6 +22,14 @@ module ActiveRecord # # This is +true+, by default on Rails 5.2 and above. class_attribute :cache_versioning, instance_writer: false, default: false + + ## + # :singleton-method: + # Indicates whether to use a stable #cache_key method that is accompanied + # by a changing version in the #cache_version method on collections. + # + # This is +false+, by default until Rails 6.1. + class_attribute :collection_cache_versioning, instance_writer: false, default: false end # Returns a +String+, which Action Pack uses for constructing a URL to this @@ -152,6 +160,10 @@ module ActiveRecord end end end + + def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: + collection.send(:compute_cache_key, timestamp_column) + end end private @@ -180,7 +192,7 @@ module ActiveRecord # raw_timestamp_to_cache_version(timestamp) # # => "20181015200215266505" # - # Postgres truncates trailing zeros, + # PostgreSQL truncates trailing zeros, # https://github.com/postgres/postgres/commit/3e1beda2cde3495f41290e1ece5d544525810214 # to account for this we pad the output with zeros def raw_timestamp_to_cache_version(timestamp) diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index 88b0c828ae..e6166581f1 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -17,7 +17,7 @@ module ActiveRecord end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{internal_metadata_table_name}#{table_name_suffix}" end def []=(key, value) @@ -44,6 +44,10 @@ module ActiveRecord end end end + + def drop_table + connection.drop_table table_name, if_exists: true + end end end end diff --git a/activerecord/lib/active_record/locking/optimistic.rb b/activerecord/lib/active_record/locking/optimistic.rb index 4a3a31fc95..6711ee9bf4 100644 --- a/activerecord/lib/active_record/locking/optimistic.rb +++ b/activerecord/lib/active_record/locking/optimistic.rb @@ -71,9 +71,8 @@ module ActiveRecord end def _touch_row(attribute_names, time) + @_touch_attr_names << self.class.locking_column if locking_enabled? super - ensure - clear_attribute_change(self.class.locking_column) if locking_enabled? end def _update_row(attribute_names, attempted_action = "update") @@ -88,7 +87,7 @@ module ActiveRecord affected_rows = self.class._update_record( attributes_with_values(attribute_names), - self.class.primary_key => id_in_database, + @primary_key => id_in_database, locking_column => previous_lock_value ) @@ -111,7 +110,7 @@ module ActiveRecord locking_column = self.class.locking_column affected_rows = self.class._delete_record( - self.class.primary_key => id_in_database, + @primary_key => id_in_database, locking_column => read_attribute_before_type_cast(locking_column) ) diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index 6b84431343..6248c2f578 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -110,7 +110,7 @@ module ActiveRecord end def extract_query_source_location(locations) - backtrace_cleaner.clean(locations).first + backtrace_cleaner.clean(locations.lazy).first end end end diff --git a/activerecord/lib/active_record/middleware/database_selector.rb b/activerecord/lib/active_record/middleware/database_selector.rb index aad263695b..93a1a39c3e 100644 --- a/activerecord/lib/active_record/middleware/database_selector.rb +++ b/activerecord/lib/active_record/middleware/database_selector.rb @@ -11,7 +11,7 @@ module ActiveRecord # behavior. # # The resolver class defines when the application should switch (i.e. read - # from the primary if a write occurred less than 2 seconds ago) and an + # from the primary if a write occurred less than 2 seconds ago) and a # resolver context class that sets a value that helps the resolver class # decide when to switch. # @@ -35,10 +35,10 @@ module ActiveRecord # config.active_record.database_resolver = MyResolver # config.active_record.database_resolver_context = MyResolver::MySession class DatabaseSelector - def initialize(app, resolver_klass = Resolver, context_klass = Resolver::Session, options = {}) + def initialize(app, resolver_klass = nil, context_klass = nil, options = {}) @app = app - @resolver_klass = resolver_klass - @context_klass = context_klass + @resolver_klass = resolver_klass || Resolver + @context_klass = context_klass || Resolver::Session @options = options end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 4b2e9ed81c..f20edbeb93 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -4,9 +4,10 @@ require "benchmark" require "set" require "zlib" require "active_support/core_ext/module/attribute_accessors" +require "active_support/actionable_error" module ActiveRecord - class MigrationError < ActiveRecordError#:nodoc: + class MigrationError < ActiveRecordError #:nodoc: def initialize(message = nil) message = "\n\n#{message}\n\n" if message super @@ -87,7 +88,7 @@ module ActiveRecord class IrreversibleMigration < MigrationError end - class DuplicateMigrationVersionError < MigrationError#:nodoc: + class DuplicateMigrationVersionError < MigrationError #:nodoc: def initialize(version = nil) if version super("Multiple migrations have the version number #{version}.") @@ -97,7 +98,7 @@ module ActiveRecord end end - class DuplicateMigrationNameError < MigrationError#:nodoc: + class DuplicateMigrationNameError < MigrationError #:nodoc: def initialize(name = nil) if name super("Multiple migrations have the name #{name}.") @@ -117,7 +118,7 @@ module ActiveRecord end end - class IllegalMigrationNameError < MigrationError#:nodoc: + class IllegalMigrationNameError < MigrationError #:nodoc: def initialize(name = nil) if name super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed).") @@ -127,7 +128,13 @@ module ActiveRecord end end - class PendingMigrationError < MigrationError#:nodoc: + class PendingMigrationError < MigrationError #:nodoc: + include ActiveSupport::ActionableError + + action "Run pending migrations" do + ActiveRecord::Tasks::DatabaseTasks.migrate + end + def initialize(message = nil) if !message && defined?(Rails.env) super("Migrations are pending. To resolve this issue, run:\n\n rails db:migrate RAILS_ENV=#{::Rails.env}") @@ -308,7 +315,7 @@ module ActiveRecord # named +column_name+ from the table called +table_name+. # * <tt>remove_columns(table_name, *column_names)</tt>: Removes the given # columns from the table definition. - # * <tt>remove_foreign_key(from_table, options_or_to_table)</tt>: Removes the + # * <tt>remove_foreign_key(from_table, to_table = nil, **options)</tt>: Removes the # given foreign key from the table called +table_name+. # * <tt>remove_index(table_name, column: column_names)</tt>: Removes the index # specified by +column_names+. @@ -520,10 +527,10 @@ module ActiveRecord autoload :Compatibility, "active_record/migration/compatibility" # This must be defined before the inherited hook, below - class Current < Migration # :nodoc: + class Current < Migration #:nodoc: end - def self.inherited(subclass) # :nodoc: + def self.inherited(subclass) #:nodoc: super if subclass.superclass == Migration raise StandardError, "Directly inheriting from ActiveRecord::Migration is not supported. " \ @@ -541,7 +548,7 @@ module ActiveRecord ActiveRecord::VERSION::STRING.to_f end - MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ # :nodoc: + MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ #:nodoc: # This class is used to verify that all migrations have been run before # loading a web page if <tt>config.active_record.migration_error</tt> is set to :page_load @@ -568,10 +575,10 @@ module ActiveRecord end class << self - attr_accessor :delegate # :nodoc: - attr_accessor :disable_ddl_transaction # :nodoc: + attr_accessor :delegate #:nodoc: + attr_accessor :disable_ddl_transaction #:nodoc: - def nearest_delegate # :nodoc: + def nearest_delegate #:nodoc: delegate || superclass.nearest_delegate end @@ -595,13 +602,13 @@ module ActiveRecord end end - def maintain_test_schema! # :nodoc: + def maintain_test_schema! #:nodoc: if ActiveRecord::Base.maintain_test_schema suppress_messages { load_schema_if_pending! } end end - def method_missing(name, *args, &block) # :nodoc: + def method_missing(name, *args, &block) #:nodoc: nearest_delegate.send(name, *args, &block) end @@ -618,7 +625,7 @@ module ActiveRecord end end - def disable_ddl_transaction # :nodoc: + def disable_ddl_transaction #:nodoc: self.class.disable_ddl_transaction end @@ -693,7 +700,7 @@ module ActiveRecord connection.respond_to?(:reverting) && connection.reverting end - ReversibleBlockHelper = Struct.new(:reverting) do # :nodoc: + ReversibleBlockHelper = Struct.new(:reverting) do #:nodoc: def up yield unless reverting end @@ -1006,7 +1013,7 @@ module ActiveRecord end end - class MigrationContext # :nodoc: + class MigrationContext #:nodoc: attr_reader :migrations_paths def initialize(migrations_paths) @@ -1165,7 +1172,7 @@ module ActiveRecord end end - class Migrator # :nodoc: + class Migrator #:nodoc: class << self attr_accessor :migrations_paths @@ -1323,7 +1330,7 @@ module ActiveRecord def record_version_state_after_migrating(version) if down? migrated.delete(version) - ActiveRecord::SchemaMigration.where(version: version.to_s).delete_all + ActiveRecord::SchemaMigration.delete_by(version: version.to_s) else migrated << version ActiveRecord::SchemaMigration.create!(version: version.to_s) diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index 82f5121d94..efed4b0e26 100644 --- a/activerecord/lib/active_record/migration/command_recorder.rb +++ b/activerecord/lib/active_record/migration/command_recorder.rb @@ -14,6 +14,8 @@ module ActiveRecord # * change_column # * change_column_default (must supply a :from and :to option) # * change_column_null + # * change_column_comment (must supply a :from and :to option) + # * change_table_comment (must supply a :from and :to option) # * create_join_table # * create_table # * disable_extension @@ -35,7 +37,8 @@ module ActiveRecord :change_column_default, :add_reference, :remove_reference, :transaction, :drop_join_table, :drop_table, :execute_block, :enable_extension, :disable_extension, :change_column, :execute, :remove_columns, :change_column_null, - :add_foreign_key, :remove_foreign_key + :add_foreign_key, :remove_foreign_key, + :change_column_comment, :change_table_comment ] include JoinTable @@ -231,28 +234,39 @@ module ActiveRecord end def invert_remove_foreign_key(args) - from_table, options_or_to_table, options_or_nil = args + options = args.extract_options! + from_table, to_table = args - to_table = if options_or_to_table.is_a?(Hash) - options_or_to_table[:to_table] - else - options_or_to_table - end - - remove_options = if options_or_to_table.is_a?(Hash) - options_or_to_table.except(:to_table) - else - options_or_nil - end + to_table ||= options.delete(:to_table) raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? reversed_args = [from_table, to_table] - reversed_args << remove_options if remove_options.present? + reversed_args << options unless options.empty? [:add_foreign_key, reversed_args] end + def invert_change_column_comment(args) + table, column, options = *args + + unless options && options.is_a?(Hash) && options.has_key?(:from) && options.has_key?(:to) + raise ActiveRecord::IrreversibleMigration, "change_column_comment is only reversible if given a :from and :to option." + end + + [:change_column_comment, [table, column, from: options[:to], to: options[:from]]] + end + + def invert_change_table_comment(args) + table, options = *args + + unless options && options.is_a?(Hash) && options.has_key?(:from) && options.has_key?(:to) + raise ActiveRecord::IrreversibleMigration, "change_table_comment is only reversible if given a :from and :to option." + end + + [:change_table_comment, [table, from: options[:to], to: options[:from]]] + end + def respond_to_missing?(method, _) super || delegate.respond_to?(method) end diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 94906a2943..ef78a9161e 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -13,7 +13,10 @@ module ActiveRecord const_get(name) end - V6_0 = Current + V6_1 = Current + + class V6_0 < V6_1 + end class V5_2 < V6_0 module TableDefinition @@ -27,6 +30,16 @@ module ActiveRecord def invert_transaction(args, &block) [:transaction, args, block] end + + def invert_change_column_comment(args) + table_name, column_name, comment = args + [:change_column_comment, [table_name, column_name, from: comment, to: comment]] + end + + def invert_change_table_comment(args) + table_name, comment = args + [:change_table_comment, [table_name, from: comment, to: comment]] + end end def create_table(table_name, **options) @@ -77,18 +90,18 @@ module ActiveRecord class V5_1 < V5_2 def change_column(table_name, column_name, type, options = {}) - if adapter_name == "PostgreSQL" + if connection.adapter_name == "PostgreSQL" super(table_name, column_name, type, options.except(:default, :null, :comment)) - change_column_default(table_name, column_name, options[:default]) if options.key?(:default) - change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) - change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) + connection.change_column_default(table_name, column_name, options[:default]) if options.key?(:default) + connection.change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + connection.change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) else super end end def create_table(table_name, options = {}) - if adapter_name == "Mysql2" + if connection.adapter_name == "Mysql2" super(table_name, options: "ENGINE=InnoDB", **options) else super @@ -110,13 +123,13 @@ module ActiveRecord end def create_table(table_name, options = {}) - if adapter_name == "PostgreSQL" + if connection.adapter_name == "PostgreSQL" if options[:id] == :uuid && !options.key?(:default) options[:default] = "uuid_generate_v4()" end end - unless adapter_name == "Mysql2" && options[:id] == :bigint + unless connection.adapter_name == "Mysql2" && options[:id] == :bigint if [:integer, :bigint].include?(options[:id]) && !options.key?(:default) options[:default] = nil end @@ -190,7 +203,7 @@ module ActiveRecord if options[:name].present? options[:name].to_s else - index_name(table_name, column: column_names) + connection.index_name(table_name, column: column_names) end super end @@ -210,15 +223,17 @@ module ActiveRecord end def index_name_for_remove(table_name, options = {}) - index_name = index_name(table_name, options) + index_name = connection.index_name(table_name, options) - unless index_name_exists?(table_name, index_name) + unless connection.index_name_exists?(table_name, index_name) if options.is_a?(Hash) && options.has_key?(:name) options_without_column = options.dup options_without_column.delete :column - index_name_without_column = index_name(table_name, options_without_column) + index_name_without_column = connection.index_name(table_name, options_without_column) - return index_name_without_column if index_name_exists?(table_name, index_name_without_column) + if connection.index_name_exists?(table_name, index_name_without_column) + return index_name_without_column + end end raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' does not exist" diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 2213fbefb4..a58c5fd48a 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_record/insert_all" + module ActiveRecord # = Active Record \Persistence module Persistence @@ -55,6 +57,192 @@ module ActiveRecord end end + # Inserts a single record into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all</tt> for documentation. + def insert(attributes, returning: nil, unique_by: nil) + insert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Inserts multiple records into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Rows are considered to be unique by every unique index on the table. Any + # duplicate rows are skipped. + # Override with <tt>:unique_by</tt> (see below). + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # [:unique_by] + # (PostgreSQL and SQLite only) By default rows are considered to be unique + # by every unique index on the table. Any duplicate rows are skipped. + # + # To skip rows according to just one unique index pass <tt>:unique_by</tt>. + # + # Consider a Book model where no duplicate ISBNs make sense, but if any + # row has an existing id, or is not unique by another unique index, + # <tt>ActiveRecord::RecordNotUnique</tt> is raised. + # + # Unique indexes can be identified by columns or name: + # + # unique_by: :isbn + # unique_by: %i[ author_id name ] + # unique_by: :index_books_on_isbn + # + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. + # + # ==== Example + # + # # Insert records and skip inserting any duplicates. + # # Here "Eloquent Ruby" is skipped because its id is not unique. + # + # Book.insert_all([ + # { id: 1, title: "Rework", author: "David" }, + # { id: 1, title: "Eloquent Ruby", author: "Russ" } + # ]) + def insert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by).execute + end + + # Inserts a single record into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all!</tt> for more. + def insert!(attributes, returning: nil) + insert_all!([ attributes ], returning: returning) + end + + # Inserts multiple records into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Raises <tt>ActiveRecord::RecordNotUnique</tt> if any rows violate a + # unique index on the table. In that case, no rows are inserted. + # + # To skip duplicate rows, see <tt>ActiveRecord::Persistence#insert_all</tt>. + # To replace them, see <tt>ActiveRecord::Persistence#upsert_all</tt>. + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # ==== Examples + # + # # Insert multiple records + # Book.insert_all!([ + # { title: "Rework", author: "David" }, + # { title: "Eloquent Ruby", author: "Russ" } + # ]) + # + # # Raises ActiveRecord::RecordNotUnique because "Eloquent Ruby" + # # does not have a unique id. + # Book.insert_all!([ + # { id: 1, title: "Rework", author: "David" }, + # { id: 1, title: "Eloquent Ruby", author: "Russ" } + # ]) + def insert_all!(attributes, returning: nil) + InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute + end + + # Updates or inserts (upserts) a single record into the database in a + # single SQL INSERT statement. It does not instantiate any models nor does + # it trigger Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#upsert_all</tt> for documentation. + def upsert(attributes, returning: nil, unique_by: nil) + upsert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Updates or inserts (upserts) multiple records into the database in a + # single SQL INSERT statement. It does not instantiate any models nor does + # it trigger Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # [:unique_by] + # (PostgreSQL and SQLite only) By default rows are considered to be unique + # by every unique index on the table. Any duplicate rows are skipped. + # + # To skip rows according to just one unique index pass <tt>:unique_by</tt>. + # + # Consider a Book model where no duplicate ISBNs make sense, but if any + # row has an existing id, or is not unique by another unique index, + # <tt>ActiveRecord::RecordNotUnique</tt> is raised. + # + # Unique indexes can be identified by columns or name: + # + # unique_by: :isbn + # unique_by: %i[ author_id name ] + # unique_by: :index_books_on_isbn + # + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. + # + # ==== Examples + # + # # Inserts multiple records, performing an upsert when records have duplicate ISBNs. + # # Here "Eloquent Ruby" overwrites "Rework" because its ISBN is duplicate. + # + # Book.upsert_all([ + # { title: "Rework", author: "David", isbn: "1" }, + # { title: "Eloquent Ruby", author: "Russ", isbn: "1" } + # ], unique_by: :isbn) + # + # Book.find_by(isbn: "1").title # => "Eloquent Ruby" + def upsert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :update, returning: returning, unique_by: unique_by).execute + end + # Given an attributes hash, +instantiate+ returns a new instance of # the appropriate class. Accepts only keys as strings. # @@ -142,7 +330,7 @@ module ActiveRecord end end - # Deletes the row with a primary key matching the +id+ argument, using a + # Deletes the row with a primary key matching the +id+ argument, using an # SQL +DELETE+ statement, and returns the number of rows deleted. Active # Record objects are not instantiated, so the object's callbacks are not # executed, including any <tt>:dependent</tt> association options. @@ -161,10 +349,11 @@ module ActiveRecord # # Delete multiple rows # Todo.delete([2,3,4]) def delete(id_or_array) - where(primary_key => id_or_array).delete_all + delete_by(primary_key => id_or_array) end def _insert_record(values) # :nodoc: + primary_key = self.primary_key primary_key_value = nil if primary_key && Hash === values @@ -235,20 +424,20 @@ module ActiveRecord # Returns true if this object hasn't been saved yet -- that is, a record # for the object doesn't exist in the database yet; otherwise, returns false. def new_record? - sync_with_transaction_state + sync_with_transaction_state if @transaction_state&.finalized? @new_record end # Returns true if this object has been destroyed, otherwise returns false. def destroyed? - sync_with_transaction_state + sync_with_transaction_state if @transaction_state&.finalized? @destroyed end # Returns true if the record is persisted, i.e. it's not a new record and it was # not destroyed, otherwise returns false. def persisted? - sync_with_transaction_state + sync_with_transaction_state if @transaction_state&.finalized? !(@new_record || @destroyed) end @@ -342,7 +531,6 @@ module ActiveRecord def destroy _raise_readonly_record_error if readonly? destroy_associations - self.class.connection.add_transaction_record(self) @_trigger_destroy_callback = if persisted? destroy_row > 0 else @@ -380,7 +568,6 @@ module ActiveRecord became.send(:initialize) became.instance_variable_set("@attributes", @attributes) became.instance_variable_set("@mutations_from_database", @mutations_from_database ||= nil) - became.instance_variable_set("@changed_attributes", attributes_changed_by_setter) became.instance_variable_set("@new_record", new_record?) became.instance_variable_set("@destroyed", destroyed?) became.errors.copy!(errors) @@ -477,8 +664,13 @@ module ActiveRecord raise ActiveRecordError, "cannot update a new record" if new_record? raise ActiveRecordError, "cannot update a destroyed record" if destroyed? + attributes = attributes.transform_keys do |key| + name = key.to_s + self.class.attribute_aliases[name] || name + end + attributes.each_key do |key| - verify_readonly_attribute(key.to_s) + verify_readonly_attribute(key) end id_in_database = self.id_in_database @@ -488,7 +680,7 @@ module ActiveRecord affected_rows = self.class._update_record( attributes, - self.class.primary_key => id_in_database + @primary_key => id_in_database ) affected_rows == 1 @@ -657,15 +849,12 @@ module ActiveRecord # ball.touch(:updated_at) # => raises ActiveRecordError # def touch(*names, time: nil) - unless persisted? - raise ActiveRecordError, <<-MSG.squish - cannot touch on a new or destroyed record object. Consider using - persisted?, new_record?, or destroyed? before touching - MSG - end + _raise_record_not_touched_error unless persisted? attribute_names = timestamp_attributes_for_update_in_model - attribute_names |= names.map(&:to_s) + attribute_names |= names.map!(&:to_s).map! { |name| + self.class.attribute_aliases[name] || name + } unless attribute_names.empty? affected_rows = _touch_row(attribute_names, time) @@ -686,15 +875,14 @@ module ActiveRecord end def _delete_row - self.class._delete_record(self.class.primary_key => id_in_database) + self.class._delete_record(@primary_key => id_in_database) end def _touch_row(attribute_names, time) time ||= current_time_from_proper_timezone attribute_names.each do |attr_name| - write_attribute(attr_name, time) - clear_attribute_change(attr_name) + _write_attribute(attr_name, time) end _update_row(attribute_names, "touch") @@ -703,14 +891,14 @@ module ActiveRecord def _update_row(attribute_names, attempted_action = "update") self.class._update_record( attributes_with_values(attribute_names), - self.class.primary_key => id_in_database + @primary_key => id_in_database ) end - def create_or_update(*args, &block) + def create_or_update(**, &block) _raise_readonly_record_error if readonly? return false if destroyed? - result = new_record? ? _create_record(&block) : _update_record(*args, &block) + result = new_record? ? _create_record(&block) : _update_record(&block) result != false end @@ -741,7 +929,7 @@ module ActiveRecord attributes_with_values(attribute_names) ) - self.id ||= new_id if self.class.primary_key + self.id ||= new_id if @primary_key @new_record = false @@ -761,14 +949,21 @@ module ActiveRecord @_association_destroy_exception = nil end + def _raise_readonly_record_error + raise ReadOnlyRecord, "#{self.class} is marked as readonly" + end + + def _raise_record_not_touched_error + raise ActiveRecordError, <<~MSG.squish + Cannot touch on a new or destroyed record object. Consider using + persisted?, new_record?, or destroyed? before touching. + MSG + end + # The name of the method used to touch a +belongs_to+ association when the # +:touch+ option is used. def belongs_to_touch_method :touch end - - def _raise_readonly_record_error - raise ReadOnlyRecord, "#{self.class} is marked as readonly" - end end end diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 8c1b2e2be1..08cfc3fe5f 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -2,18 +2,23 @@ module ActiveRecord module Querying - delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, to: :all - delegate :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, to: :all - delegate :first_or_create, :first_or_create!, :first_or_initialize, to: :all - delegate :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, to: :all - delegate :find_by, :find_by!, to: :all - delegate :destroy_all, :delete_all, :update_all, to: :all - delegate :find_each, :find_in_batches, :in_batches, to: :all - delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, - :having, :create_with, :distinct, :references, :none, :unscope, :merge, to: :all - delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all - delegate :pluck, :pick, :ids, to: :all + QUERYING_METHODS = [ + :find, :find_by, :find_by!, :take, :take!, :first, :first!, :last, :last!, + :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, + :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, + :exists?, :any?, :many?, :none?, :one?, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, + :create_or_find_by, :create_or_find_by!, + :destroy_all, :delete_all, :update_all, :touch_all, :destroy_by, :delete_by, + :find_each, :find_in_batches, :in_batches, + :select, :reselect, :order, :reorder, :group, :limit, :offset, :joins, :left_joins, :left_outer_joins, + :where, :rewhere, :preload, :extract_associated, :eager_load, :includes, :from, :lock, :readonly, :extending, :or, + :having, :create_with, :distinct, :references, :none, :unscope, :optimizer_hints, :merge, :except, :only, + :count, :average, :minimum, :maximum, :sum, :calculate, :annotate, + :pluck, :pick, :ids + ].freeze # :nodoc: + delegate(*QUERYING_METHODS, to: :all) # Executes a custom SQL query against your database and returns all the results. The results will # be returned as an array, with the requested columns encapsulated as attributes of the model you call @@ -40,8 +45,7 @@ module ActiveRecord def find_by_sql(sql, binds = [], preparable: nil, &block) result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable) column_types = result_set.column_types.dup - cached_columns_hash = connection.schema_cache.columns_hash(table_name) - cached_columns_hash.each_key { |k| column_types.delete k } + attribute_types.each_key { |k| column_types.delete k } message_bus = ActiveSupport::Notifications.instrumenter payload = { diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 8de06e8466..e0bc5180c0 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -66,6 +66,11 @@ db_namespace = namespace :db do end end + # desc "Truncates tables of each database for current environment" + task truncate_all: [:load_config, :check_protected_environments] do + ActiveRecord::Tasks::DatabaseTasks.truncate_all + end + # desc "Empty the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:purge:all to purge all databases in the config). Without RAILS_ENV it defaults to purging the development and test databases." task purge: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.purge_current @@ -73,7 +78,7 @@ db_namespace = namespace :db do desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)." task migrate: :load_config do - ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.config) ActiveRecord::Tasks::DatabaseTasks.migrate end @@ -123,6 +128,8 @@ db_namespace = namespace :db do # desc 'Runs the "up" for a given migration VERSION.' task up: :load_config do + ActiveRecord::Tasks::DatabaseTasks.raise_for_multi_db(command: "db:migrate:up") + raise "VERSION is required" if !ENV["VERSION"] || ENV["VERSION"].empty? ActiveRecord::Tasks::DatabaseTasks.check_target_version @@ -134,8 +141,29 @@ db_namespace = namespace :db do db_namespace["_dump"].invoke end + namespace :up do + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + task spec_name => :load_config do + raise "VERSION is required" if !ENV["VERSION"] || ENV["VERSION"].empty? + + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) + + ActiveRecord::Base.establish_connection(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.check_target_version + ActiveRecord::Base.connection.migration_context.run( + :up, + ActiveRecord::Tasks::DatabaseTasks.target_version + ) + + db_namespace["_dump"].invoke + end + end + end + # desc 'Runs the "down" for a given migration VERSION.' task down: :load_config do + ActiveRecord::Tasks::DatabaseTasks.raise_for_multi_db(command: "db:migrate:down") + raise "VERSION is required - To go down one migration, use db:rollback" if !ENV["VERSION"] || ENV["VERSION"].empty? ActiveRecord::Tasks::DatabaseTasks.check_target_version @@ -147,9 +175,28 @@ db_namespace = namespace :db do db_namespace["_dump"].invoke end + namespace :down do + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + task spec_name => :load_config do + raise "VERSION is required" if !ENV["VERSION"] || ENV["VERSION"].empty? + + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) + + ActiveRecord::Base.establish_connection(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.check_target_version + ActiveRecord::Base.connection.migration_context.run( + :down, + ActiveRecord::Tasks::DatabaseTasks.target_version + ) + + db_namespace["_dump"].invoke + end + end + end + desc "Display status of migrations" task status: :load_config do - ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.config) ActiveRecord::Tasks::DatabaseTasks.migrate_status end @@ -217,12 +264,27 @@ db_namespace = namespace :db do desc "Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)" task setup: ["db:schema:load_if_ruby", "db:structure:load_if_sql", :seed] + desc "Runs setup if database does not exist, or runs migrations if it does" + task prepare: :load_config do + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + db_namespace["migrate"].invoke + rescue ActiveRecord::NoDatabaseError + db_namespace["setup"].invoke + end + end + desc "Loads the seed data from db/seeds.rb" task seed: :load_config do db_namespace["abort_if_pending_migrations"].invoke ActiveRecord::Tasks::DatabaseTasks.load_seed end + namespace :seed do + desc "Truncates tables of each database for current environment and loads the seeds" + task replant: [:load_config, :truncate_all, :seed] + end + namespace :fixtures do desc "Loads fixtures into the current environment's database. Load specific fixtures using FIXTURES=x,y. Load from subdirectory in test/fixtures using FIXTURES_DIR=z. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." task load: :load_config do @@ -275,7 +337,7 @@ db_namespace = namespace :db do desc "Creates a db/schema.rb file that is portable against any DB supported by Active Record" task dump: :load_config do require "active_record/schema_dumper" - ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :ruby) File.open(filename, "w:utf-8") do |file| ActiveRecord::Base.establish_connection(db_config.config) @@ -298,7 +360,7 @@ db_namespace = namespace :db do namespace :cache do desc "Creates a db/schema_cache.yml file." task dump: :load_config do - ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.config) filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config.spec_name) ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache( @@ -310,7 +372,7 @@ db_namespace = namespace :db do desc "Clears a db/schema_cache.yml file." task clear: :load_config do - ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config.spec_name) rm_f filename, verbose: false end @@ -321,7 +383,7 @@ db_namespace = namespace :db do namespace :structure do desc "Dumps the database structure to db/structure.sql. Specify another file with SCHEMA=db/my_structure.sql" task dump: :load_config do - ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.config) filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :sql) ActiveRecord::Tasks::DatabaseTasks.structure_dump(db_config.config, filename) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 6d2f75a3ae..eefda2b8f4 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -21,12 +21,12 @@ module ActiveRecord def add_reflection(ar, name, reflection) ar.clear_reflections_cache - name = name.to_s + name = -name.to_s ar._reflections = ar._reflections.except(name).merge!(name => reflection) end def add_aggregate_reflection(ar, name, reflection) - ar.aggregate_reflections = ar.aggregate_reflections.merge(name.to_s => reflection) + ar.aggregate_reflections = ar.aggregate_reflections.merge(-name.to_s => reflection) end private @@ -178,28 +178,24 @@ module ActiveRecord scope ? [scope] : [] end - def build_join_constraint(table, foreign_table) - key = join_keys.key - foreign_key = join_keys.foreign_key - - constraint = table[key].eq(foreign_table[foreign_key]) - - if klass.finder_needs_type_condition? - table.create_and([constraint, klass.send(:type_condition, table)]) - else - constraint - end - end - - def join_scope(table, foreign_klass) + def join_scope(table, foreign_table, foreign_klass) predicate_builder = predicate_builder(table) scope_chain_items = join_scopes(table, predicate_builder) klass_scope = klass_join_scope(table, predicate_builder) + key = join_keys.key + foreign_key = join_keys.foreign_key + + klass_scope.where!(table[key].eq(foreign_table[foreign_key])) + if type klass_scope.where!(type => foreign_klass.polymorphic_name) end + if klass.finder_needs_type_condition? + klass_scope.where!(klass.send(:type_condition, table)) + end + scope_chain_items.inject(klass_scope, &:merge!) end @@ -481,7 +477,7 @@ module ActiveRecord def check_preloadable! return unless scope - if scope.arity > 0 + unless scope.arity == 0 raise ArgumentError, <<-MSG.squish The association scope '#{name}' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 347d745d19..add95f6a0a 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -5,7 +5,7 @@ module ActiveRecord class Relation MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_outer_joins, :references, - :extending, :unscope] + :extending, :unscope, :optimizer_hints, :annotate] SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :skip_query_cache] @@ -67,7 +67,7 @@ module ActiveRecord # user = users.new { |user| user.name = 'Oscar' } # user.name # => Oscar def new(attributes = nil, &block) - block = klass.current_scope_restoring_block(&block) + block = _deprecated_scope_block("new", &block) scoping { klass.new(attributes, &block) } end @@ -96,7 +96,8 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| create(attr, &block) } else - new(attributes, &block).tap(&:save) + block = _deprecated_scope_block("create", &block) + scoping { klass.create(attributes, &block) } end end @@ -110,7 +111,8 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| create!(attr, &block) } else - new(attributes, &block).tap(&:save!) + block = _deprecated_scope_block("create!", &block) + scoping { klass.create!(attributes, &block) } end end @@ -195,6 +197,10 @@ module ActiveRecord # if a DELETE between those two statements is run by another client. But for most applications, # that's a significantly less likely condition to hit. # * It relies on exception handling to handle control flow, which may be marginally slower. + # * The primary key may auto-increment on each create, even if it fails. This can accelerate + # the problem of running out of integers, if the underlying table is still stuck on a primary + # key of type int (note: All Rails apps since 5.1+ have defaulted to bigint, which is not liable + # to this problem). # # This method will return a record if all given attributes are covered by unique constraints # (unless the INSERT -> DELETE -> SELECT race condition is triggered), but if creation was attempted @@ -285,31 +291,99 @@ module ActiveRecord limit_value ? records.many? : size > 1 end - # Returns a cache key that can be used to identify the records fetched by - # this query. The cache key is built with a fingerprint of the sql query, - # the number of records matched by the query and a timestamp of the last - # updated record. When a new record comes to match the query, or any of - # the existing records is updated or deleted, the cache key changes. + # Returns a stable cache key that can be used to identify this query. + # The cache key is built with a fingerprint of the SQL query. # - # Product.where("name like ?", "%Cosmic Encounter%").cache_key - # # => "products/query-1850ab3d302391b85b8693e941286659-1-20150714212553907087000" + # Product.where("name like ?", "%Cosmic Encounter%").cache_key + # # => "products/query-1850ab3d302391b85b8693e941286659" # - # If the collection is loaded, the method will iterate through the records - # to generate the timestamp, otherwise it will trigger one SQL query like: + # If ActiveRecord::Base.collection_cache_versioning is turned off, as it was + # in Rails 6.0 and earlier, the cache key will also include a version. # - # SELECT COUNT(*), MAX("products"."updated_at") FROM "products" WHERE (name like '%Cosmic Encounter%') + # ActiveRecord::Base.collection_cache_versioning = false + # Product.where("name like ?", "%Cosmic Encounter%").cache_key + # # => "products/query-1850ab3d302391b85b8693e941286659-1-20150714212553907087000" # # You can also pass a custom timestamp column to fetch the timestamp of the # last updated record. # # Product.where("name like ?", "%Game%").cache_key(:last_reviewed_at) - # - # You can customize the strategy to generate the key on a per model basis - # overriding ActiveRecord::Base#collection_cache_key. def cache_key(timestamp_column = :updated_at) @cache_keys ||= {} - @cache_keys[timestamp_column] ||= @klass.collection_cache_key(self, timestamp_column) + @cache_keys[timestamp_column] ||= klass.collection_cache_key(self, timestamp_column) + end + + def compute_cache_key(timestamp_column = :updated_at) # :nodoc: + query_signature = ActiveSupport::Digest.hexdigest(to_sql) + key = "#{klass.model_name.cache_key}/query-#{query_signature}" + + if cache_version(timestamp_column) + key + else + "#{key}-#{compute_cache_version(timestamp_column)}" + end + end + private :compute_cache_key + + # Returns a cache version that can be used together with the cache key to form + # a recyclable caching scheme. The cache version is built with the number of records + # matching the query, and the timestamp of the last updated record. When a new record + # comes to match the query, or any of the existing records is updated or deleted, + # the cache version changes. + # + # If the collection is loaded, the method will iterate through the records + # to generate the timestamp, otherwise it will trigger one SQL query like: + # + # SELECT COUNT(*), MAX("products"."updated_at") FROM "products" WHERE (name like '%Cosmic Encounter%') + def cache_version(timestamp_column = :updated_at) + if collection_cache_versioning + @cache_versions ||= {} + @cache_versions[timestamp_column] ||= compute_cache_version(timestamp_column) + end + end + + def compute_cache_version(timestamp_column) # :nodoc: + if loaded? || distinct_value + size = records.size + if size > 0 + timestamp = max_by(×tamp_column)._read_attribute(timestamp_column) + end + else + collection = eager_loading? ? apply_join_dependency : self + + column = connection.visitor.compile(arel_attribute(timestamp_column)) + select_values = "COUNT(*) AS #{connection.quote_column_name("size")}, MAX(%s) AS timestamp" + + if collection.has_limit_or_offset? + query = collection.select("#{column} AS collection_cache_key_timestamp") + subquery_alias = "subquery_for_cache_key" + subquery_column = "#{subquery_alias}.collection_cache_key_timestamp" + arel = query.build_subquery(subquery_alias, select_values % subquery_column) + else + query = collection.unscope(:order) + query.select_values = [select_values % column] + arel = query.arel + end + + result = connection.select_one(arel, nil) + + if result + column_type = klass.type_for_attribute(timestamp_column) + timestamp = column_type.deserialize(result["timestamp"]) + size = result["size"] + else + timestamp = nil + size = 0 + end + end + + if timestamp + "#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" + else + "#{size}" + end end + private :compute_cache_version # Scope all queries to the current scope. # @@ -321,12 +395,12 @@ module ActiveRecord # Please check unscoped if you want to remove all previous scopes (including # the default_scope) during the execution of a block. def scoping - @delegate_to_klass && klass.current_scope(true) ? yield : _scoping(self) { yield } + already_in_scope? ? yield : _scoping(self) { yield } end - def _exec_scope(*args, &block) # :nodoc: + def _exec_scope(name, *args, &block) # :nodoc: @delegate_to_klass = true - _scoping(nil) { instance_exec(*args, &block) || self } + _scoping(_deprecated_spawn(name)) { instance_exec(*args, &block) || self } ensure @delegate_to_klass = false end @@ -336,6 +410,8 @@ module ActiveRecord # trigger Active Record callbacks or validations. However, values passed to #update_all will still go through # Active Record's normal type casting and serialization. # + # Note: As Active Record callbacks are not triggered, this method will not automatically update +updated_at+/+updated_on+ columns. + # # ==== Parameters # # * +updates+ - A string, array, or hash representing the SET part of an SQL statement. @@ -370,6 +446,12 @@ module ActiveRecord stmt.wheres = arel.constraints if updates.is_a?(Hash) + if klass.locking_enabled? && + !updates.key?(klass.locking_column) && + !updates.key?(klass.locking_column.to_sym) + attr = arel_attribute(klass.locking_column) + updates[attr.name] = _increment_attribute(attr) + end stmt.set _substitute_values(updates) else stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name)) @@ -392,10 +474,7 @@ module ActiveRecord updates = {} counters.each do |counter_name, value| attr = arel_attribute(counter_name) - bind = predicate_builder.build_bind_attribute(attr.name, value.abs) - expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attr), 0) - expr = value < 0 ? expr - bind : expr + bind - updates[counter_name] = expr.expr + updates[attr.name] = _increment_attribute(attr, value) end if touch @@ -407,10 +486,10 @@ module ActiveRecord update_all updates end - # Touches all records in the current relation without instantiating records first with the updated_at/on attributes + # Touches all records in the current relation without instantiating records first with the +updated_at+/+updated_on+ attributes # set to the current time or the time specified. # This method can be passed attribute names and an optional time argument. - # If attribute names are passed, they are updated along with updated_at/on attributes. + # If attribute names are passed, they are updated along with +updated_at+/+updated_on+ attributes. # If no time argument is passed, the current time is used as default. # # === Examples @@ -431,12 +510,7 @@ module ActiveRecord # Person.where(name: 'David').touch_all # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670' WHERE \"people\".\"name\" = 'David'" def touch_all(*names, time: nil) - if klass.locking_enabled? - names << { time: time } - update_counters(klass.locking_column => 1, touch: names) - else - update_all klass.touch_attributes_with_time(*names, time: time) - end + update_all klass.touch_attributes_with_time(*names, time: time) end # Destroys the records by instantiating each @@ -479,8 +553,8 @@ module ActiveRecord # # => ActiveRecord::ActiveRecordError: delete_all doesn't support distinct def delete_all invalid_methods = INVALID_METHODS_FOR_DELETE_ALL.select do |method| - value = get_value(method) - SINGLE_VALUE_METHODS.include?(method) ? value : value.any? + value = @values[method] + method == :distinct ? value : value&.any? end if invalid_methods.any? raise ActiveRecordError.new("delete_all doesn't support #{invalid_methods.join(', ')}") @@ -505,6 +579,32 @@ module ActiveRecord affected end + # Finds and destroys all records matching the specified conditions. + # This is short-hand for <tt>relation.where(condition).destroy_all</tt>. + # Returns the collection of objects that were destroyed. + # + # If no record is found, returns empty array. + # + # Person.destroy_by(id: 13) + # Person.destroy_by(name: 'Spartacus', rating: 4) + # Person.destroy_by("published_at < ?", 2.weeks.ago) + def destroy_by(*args) + where(*args).destroy_all + end + + # Finds and deletes all records matching the specified conditions. + # This is short-hand for <tt>relation.where(condition).delete_all</tt>. + # Returns the number of rows affected. + # + # If no record is found, returns <tt>0</tt> as zero rows were affected. + # + # Person.delete_by(id: 13) + # Person.delete_by(name: 'Spartacus', rating: 4) + # Person.delete_by("published_at < ?", 2.weeks.ago) + def delete_by(*args) + where(*args).delete_all + end + # Causes the records to be loaded from the database if they have not # been loaded already. You can use this if for some reason you need # to explicitly load some records before actually using them. The @@ -525,6 +625,7 @@ module ActiveRecord def reset @delegate_to_klass = false + @_deprecated_scope_source = nil @to_sql = @arel = @loaded = @should_eager_load = nil @records = [].freeze @offsets = {} @@ -633,14 +734,39 @@ module ActiveRecord end end + attr_reader :_deprecated_scope_source # :nodoc: + protected + attr_writer :_deprecated_scope_source # :nodoc: def load_records(records) @records = records.freeze @loaded = true end + def null_relation? # :nodoc: + is_a?(NullRelation) + end + private + def already_in_scope? + @delegate_to_klass && begin + scope = klass.current_scope(true) + scope && !scope._deprecated_scope_source + end + end + + def _deprecated_spawn(name) + spawn.tap { |scope| scope._deprecated_scope_source = name } + end + + def _deprecated_scope_block(name, &block) + -> record do + klass.current_scope = _deprecated_spawn(name) + yield record if block_given? + end + end + def _scoping(scope) previous, klass.current_scope = klass.current_scope(true), scope yield @@ -659,12 +785,19 @@ module ActiveRecord end end + def _increment_attribute(attribute, value = 1) + bind = predicate_builder.build_bind_attribute(attribute.name, value.abs) + expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attribute), 0) + expr = value < 0 ? expr - bind : expr + bind + expr.expr + end + def exec_queries(&block) skip_query_cache_if_necessary do @records = if eager_loading? apply_join_dependency do |relation, join_dependency| - if ActiveRecord::NullRelation === relation + if relation.null_relation? [] else relation = join_dependency.apply_column_aliases(relation) diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index cef31bea94..0be9ba7d7b 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -129,11 +129,12 @@ module ActiveRecord relation = apply_join_dependency if operation.to_s.downcase == "count" - relation.distinct! - # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT - if (column_name == :all || column_name.nil?) && select_values.empty? - relation.order_values = [] + unless distinct_value || distinct_select?(column_name || select_for_count) + relation.distinct! + relation.select_values = [ klass.primary_key || table[Arel.star] ] end + # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT + relation.order_values = [] end relation.calculate(operation, column_name) @@ -186,11 +187,9 @@ module ActiveRecord relation = apply_join_dependency relation.pluck(*column_names) else - disallow_raw_sql!(column_names) + klass.disallow_raw_sql!(column_names) relation = spawn - relation.select_values = column_names.map { |cn| - @klass.has_attribute?(cn) || @klass.attribute_alias?(cn) ? arel_attribute(cn) : cn - } + relation.select_values = column_names result = skip_query_cache_if_necessary { klass.connection.select_all(relation.arel, nil) } result.cast_values(klass.attribute_types) end @@ -223,7 +222,6 @@ module ActiveRecord end private - def has_include?(column_name) eager_loading? || (includes_values.present? && column_name && column_name != :all) end @@ -238,10 +236,12 @@ module ActiveRecord if operation == "count" column_name ||= select_for_count if column_name == :all - if distinct && (group_values.any? || select_values.empty? && order_values.empty?) + if !distinct + distinct = distinct_select?(select_for_count) if group_values.empty? + elsif group_values.any? || select_values.empty? && order_values.empty? column_name = primary_key end - elsif column_name.is_a?(::String) && /\bDISTINCT[\s(]/i.match?(column_name) + elsif distinct_select?(column_name) distinct = nil end end @@ -253,13 +253,15 @@ module ActiveRecord end end + def distinct_select?(column_name) + column_name.is_a?(::String) && /\bDISTINCT[\s(]/i.match?(column_name) + end + def aggregate_column(column_name) return column_name if Arel::Expressions === column_name - if @klass.has_attribute?(column_name) || @klass.attribute_alias?(column_name) - @klass.arel_attribute(column_name) - else - Arel.sql(column_name == :all ? "*" : column_name.to_s) + arel_column(column_name.to_s) do |name| + Arel.sql(column_name == :all ? "*" : name) end end @@ -304,25 +306,22 @@ module ActiveRecord end def execute_grouped_calculation(operation, column_name, distinct) #:nodoc: - group_attrs = group_values + group_fields = group_values - if group_attrs.first.respond_to?(:to_sym) - association = @klass._reflect_on_association(group_attrs.first) - associated = group_attrs.size == 1 && association && association.belongs_to? # only count belongs_to associations - group_fields = Array(associated ? association.foreign_key : group_attrs) - else - group_fields = group_attrs + if group_fields.size == 1 && group_fields.first.respond_to?(:to_sym) + association = klass._reflect_on_association(group_fields.first) + associated = association && association.belongs_to? # only count belongs_to associations + group_fields = Array(association.foreign_key) if associated end group_fields = arel_columns(group_fields) - group_aliases = group_fields.map { |field| column_alias_for(field) } + group_aliases = group_fields.map { |field| + field = connection.visitor.compile(field) if Arel.arel_node?(field) + column_alias_for(field.to_s.downcase) + } group_columns = group_aliases.zip(group_fields) - if operation == "count" && column_name == :all - aggregate_alias = "count_all" - else - aggregate_alias = column_alias_for([operation, column_name].join(" ")) - end + aggregate_alias = column_alias_for("#{operation}_#{column_name.to_s.downcase}") select_values = [ operation_over_aggregate_column( @@ -341,7 +340,7 @@ module ActiveRecord } relation = except(:group).distinct!(false) - relation.group_values = group_fields + relation.group_values = group_aliases relation.select_values = select_values calculated_data = skip_query_cache_if_necessary { @klass.connection.select_all(relation.arel, nil) } @@ -367,25 +366,23 @@ module ActiveRecord end] end - # Converts the given keys to the value that the database adapter returns as + # Converts the given field to the value that the database adapter returns as # a usable column name: # # column_alias_for("users.id") # => "users_id" # column_alias_for("sum(id)") # => "sum_id" # column_alias_for("count(distinct users.id)") # => "count_distinct_users_id" # column_alias_for("count(*)") # => "count_all" - def column_alias_for(keys) - if keys.respond_to? :name - keys = "#{keys.relation.name}.#{keys.name}" - end + def column_alias_for(field) + return field if field.match?(/\A\w{,#{connection.table_alias_length}}\z/) - table_name = keys.to_s.downcase - table_name.gsub!(/\*/, "all") - table_name.gsub!(/\W+/, " ") - table_name.strip! - table_name.gsub!(/ +/, "_") + column_alias = +field + column_alias.gsub!(/\*/, "all") + column_alias.gsub!(/\W+/, " ") + column_alias.strip! + column_alias.gsub!(/ +/, "_") - @klass.connection.table_alias_for(table_name) + connection.table_alias_for(column_alias) end def type_for(field, &block) @@ -413,16 +410,17 @@ module ActiveRecord def build_count_subquery(relation, column_name, distinct) if column_name == :all + column_alias = Arel.star relation.select_values = [ Arel.sql(FinderMethods::ONE_AS_ONE) ] unless distinct else column_alias = Arel.sql("count_column") relation.select_values = [ aggregate_column(column_name).as(column_alias) ] end - subquery = relation.arel.as(Arel.sql("subquery_for_count")) - select_value = operation_over_aggregate_column(column_alias || Arel.star, "count", false) + subquery_alias = Arel.sql("subquery_for_count") + select_value = operation_over_aggregate_column(column_alias, "count", false) - Arel::SelectManager.new(subquery).project(select_value) + relation.build_subquery(subquery_alias, select_value) end end end diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index f7c3b3783f..d59331053e 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "mutex_m" + module ActiveRecord module Delegation # :nodoc: module DelegateCache # :nodoc: @@ -31,6 +33,10 @@ module ActiveRecord super end + def generate_relation_method(method) + generated_relation_methods.generate_method(method) + end + protected def include_relation_methods(delegate) superclass.include_relation_methods(delegate) unless base_class? @@ -39,27 +45,35 @@ module ActiveRecord private def generated_relation_methods - @generated_relation_methods ||= Module.new.tap do |mod| - mod_name = "GeneratedRelationMethods" - const_set mod_name, mod - private_constant mod_name + @generated_relation_methods ||= GeneratedRelationMethods.new.tap do |mod| + const_set(:GeneratedRelationMethods, mod) + private_constant :GeneratedRelationMethods end end + end + + class GeneratedRelationMethods < Module # :nodoc: + include Mutex_m + + def generate_method(method) + synchronize do + return if method_defined?(method) - def generate_relation_method(method) if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - generated_relation_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method}(*args, &block) scoping { klass.#{method}(*args, &block) } end RUBY else - generated_relation_methods.define_method(method) do |*args, &block| + define_method(method) do |*args, &block| scoping { klass.public_send(method, *args, &block) } end end end + end end + private_constant :GeneratedRelationMethods extend ActiveSupport::Concern @@ -78,39 +92,17 @@ module ActiveRecord module ClassSpecificRelation # :nodoc: extend ActiveSupport::Concern - included do - @delegation_mutex = Mutex.new - end - module ClassMethods # :nodoc: def name superclass.name end - - def delegate_to_scoped_klass(method) - @delegation_mutex.synchronize do - return if method_defined?(method) - - if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method}(*args, &block) - scoping { @klass.#{method}(*args, &block) } - end - RUBY - else - define_method method do |*args, &block| - scoping { @klass.public_send(method, *args, &block) } - end - end - end - end end private def method_missing(method, *args, &block) if @klass.respond_to?(method) - self.class.delegate_to_scoped_klass(method) + @klass.generate_relation_method(method) scoping { @klass.public_send(method, *args, &block) } else super diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 91cfc4e849..9c7ac80447 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -7,8 +7,8 @@ module ActiveRecord ONE_AS_ONE = "1 AS one" # Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]). - # If one or more records can not be found for the requested ids, then RecordNotFound will be raised. If the primary key - # is an integer, find by id coerces its arguments using +to_i+. + # If one or more records cannot be found for the requested ids, then ActiveRecord::RecordNotFound will be raised. + # If the primary key is an integer, find by id coerces its arguments by using +to_i+. # # Person.find(1) # returns the object for ID = 1 # Person.find("1") # returns the object for ID = 1 @@ -314,7 +314,7 @@ module ActiveRecord relation = construct_relation_for_exists(conditions) - skip_query_cache_if_necessary { connection.select_one(relation.arel, "#{name} Exists") } ? true : false + skip_query_cache_if_necessary { connection.select_one(relation.arel, "#{name} Exists?") } ? true : false end # This method is called whenever no records are found with either a single @@ -370,14 +370,10 @@ module ActiveRecord relation end - def construct_join_dependency(associations) - ActiveRecord::Associations::JoinDependency.new( - klass, table, associations - ) - end - def apply_join_dependency(eager_loading: group_values.empty?) - join_dependency = construct_join_dependency(eager_load_values + includes_values) + join_dependency = construct_join_dependency( + eager_load_values + includes_values, Arel::Nodes::OuterJoin + ) relation = except(:includes, :eager_load, :preload).joins!(join_dependency) if eager_loading && !using_limitable_reflections?(join_dependency.reflections) diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index 4de7465128..84fe424ef0 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -117,16 +117,16 @@ module ActiveRecord if other.klass == relation.klass relation.joins!(*other.joins_values) else - joins_dependency = other.joins_values.map do |join| + associations, others = other.joins_values.partition do |join| case join - when Hash, Symbol, Array - other.send(:construct_join_dependency, join) - else - join + when Hash, Symbol, Array; true end end - relation.joins!(*joins_dependency) + join_dependency = other.construct_join_dependency( + associations, Arel::Nodes::InnerJoin + ) + relation.joins!(join_dependency, *others) end end @@ -136,16 +136,11 @@ module ActiveRecord if other.klass == relation.klass relation.left_outer_joins!(*other.left_outer_joins_values) else - joins_dependency = other.left_outer_joins_values.map do |join| - case join - when Hash, Symbol, Array - other.send(:construct_join_dependency, join) - else - join - end - end - - relation.left_outer_joins!(*joins_dependency) + associations = other.left_outer_joins_values + join_dependency = other.construct_join_dependency( + associations, Arel::Nodes::OuterJoin + ) + relation.joins!(join_dependency) end end diff --git a/activerecord/lib/active_record/relation/query_attribute.rb b/activerecord/lib/active_record/relation/query_attribute.rb index 1dd6462d8d..cd18f27330 100644 --- a/activerecord/lib/active_record/relation/query_attribute.rb +++ b/activerecord/lib/active_record/relation/query_attribute.rb @@ -18,8 +18,10 @@ module ActiveRecord end def nil? - !value_before_type_cast.is_a?(StatementCache::Substitute) && - (value_before_type_cast.nil? || value_for_database.nil?) + unless value_before_type_cast.is_a?(StatementCache::Substitute) + value_before_type_cast.nil? || + type.respond_to?(:subtype, true) && value_for_database.nil? + end rescue ::RangeError end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index eb80aab701..b8fd2fce14 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -41,18 +41,31 @@ module ActiveRecord # # User.where.not(name: %w(Ko1 Nobu)) # # SELECT * FROM users WHERE name NOT IN ('Ko1', 'Nobu') - # - # User.where.not(name: "Jon", role: "admin") - # # SELECT * FROM users WHERE name != 'Jon' AND role != 'admin' def not(opts, *rest) opts = sanitize_forbidden_attributes(opts) where_clause = @scope.send(:where_clause_factory).build(opts, rest) @scope.references!(PredicateBuilder.references(opts)) if Hash === opts - @scope.where_clause += where_clause.invert + + if not_behaves_as_nor?(opts) + ActiveSupport::Deprecation.warn(<<~MSG.squish) + NOT conditions will no longer behave as NOR in Rails 6.1. + To continue using NOR conditions, NOT each conditions manually + (`#{ opts.keys.map { |key| ".where.not(#{key.inspect} => ...)" }.join }`). + MSG + @scope.where_clause += where_clause.invert(:nor) + else + @scope.where_clause += where_clause.invert + end + @scope end + + private + def not_behaves_as_nor?(opts) + opts.is_a?(Hash) && opts.size > 1 + end end FROZEN_EMPTY_ARRAY = [].freeze @@ -67,11 +80,13 @@ module ActiveRecord end class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{method_name} # def includes_values - get_value(#{name.inspect}) # get_value(:includes) + default = DEFAULT_VALUES[:#{name}] # default = DEFAULT_VALUES[:includes] + @values.fetch(:#{name}, default) # @values.fetch(:includes, default) end # end def #{method_name}=(value) # def includes_values=(value) - set_value(#{name.inspect}, value) # set_value(:includes, value) + assert_mutability! # assert_mutability! + @values[:#{name}] = value # @values[:includes] = value end # end CODE end @@ -100,7 +115,7 @@ module ActiveRecord # # === conditions # - # If you want to add conditions to your included models you'll have + # If you want to add string conditions to your included models, you'll have # to explicitly reference them. For example: # # User.includes(:posts).where('posts.name = ?', 'example') @@ -111,6 +126,12 @@ module ActiveRecord # # Note that #includes works with association names while #references needs # the actual table name. + # + # If you pass the conditions via hash, you don't need to call #references + # explicitly, as #where references the tables for you. For example, this + # will work correctly: + # + # User.includes(:posts).where(posts: { name: 'example' }) def includes(*args) check_if_method_has_arguments!(:includes, args) spawn.includes!(*args) @@ -154,6 +175,19 @@ module ActiveRecord self end + # Extracts a named +association+ from the relation. The named association is first preloaded, + # then the individual association records are collected from the relation. Like so: + # + # account.memberships.extract_associated(:user) + # # => Returns collection of User records + # + # This is short-hand for: + # + # account.memberships.preload(:user).collect(&:user) + def extract_associated(association) + preload(association).collect(&association) + end + # Use to indicate that the given +table_names+ are referenced by an SQL string, # and should therefore be JOINed in any query rather than loaded separately. # This method only works in conjunction with #includes. @@ -233,13 +267,31 @@ module ActiveRecord def _select!(*fields) # :nodoc: fields.reject!(&:blank?) fields.flatten! - fields.map! do |field| - klass.attribute_alias?(field) ? klass.attribute_alias(field).to_sym : field - end self.select_values += fields self end + # Allows you to change a previously set select statement. + # + # Post.select(:title, :body) + # # SELECT `posts`.`title`, `posts`.`body` FROM `posts` + # + # Post.select(:title, :body).reselect(:created_at) + # # SELECT `posts`.`created_at` FROM `posts` + # + # This is short-hand for <tt>unscope(:select).select(fields)</tt>. + # Note that we're unscoping the entire select statement. + def reselect(*args) + check_if_method_has_arguments!(:reselect, args) + spawn.reselect!(*args) + end + + # Same as #reselect but operates on relation in-place instead of copying. + def reselect!(*args) # :nodoc: + self.select_values = args + self + end + # Allows to specify a group attribute: # # User.group(:name) @@ -328,8 +380,8 @@ module ActiveRecord end VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock, - :limit, :offset, :joins, :left_outer_joins, - :includes, :from, :readonly, :having]) + :limit, :offset, :joins, :left_outer_joins, :annotate, + :includes, :from, :readonly, :having, :optimizer_hints]) # Removes an unwanted relation that is already defined on a chain of relations. # This is useful when passing around chains of relations and would like to @@ -380,7 +432,8 @@ module ActiveRecord if !VALID_UNSCOPING_VALUES.include?(scope) raise ArgumentError, "Called unscope() with invalid unscoping argument ':#{scope}'. Valid arguments are :#{VALID_UNSCOPING_VALUES.to_a.join(", :")}." end - set_value(scope, DEFAULT_VALUES[scope]) + assert_mutability! + @values[scope] = DEFAULT_VALUES[scope] when Hash scope.each do |key, target_value| if key != :where @@ -880,6 +933,29 @@ module ActiveRecord self end + # Specify optimizer hints to be used in the SELECT statement. + # + # Example (for MySQL): + # + # Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)") + # # SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics` + # + # Example (for PostgreSQL with pg_hint_plan): + # + # Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)") + # # SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics" + def optimizer_hints(*args) + check_if_method_has_arguments!(:optimizer_hints, args) + spawn.optimizer_hints!(*args) + end + + def optimizer_hints!(*args) # :nodoc: + args.flatten! + + self.optimizer_hints_values += args + self + end + # Reverse the existing order clause on the relation. # # User.order('name ASC').reverse_order # generated SQL has 'ORDER BY name DESC' @@ -904,23 +980,47 @@ module ActiveRecord self end + # Adds an SQL comment to queries generated from this relation. For example: + # + # User.annotate("selecting user names").select(:name) + # # SELECT "users"."name" FROM "users" /* selecting user names */ + # + # User.annotate("selecting", "user", "names").select(:name) + # # SELECT "users"."name" FROM "users" /* selecting */ /* user */ /* names */ + # + # The SQL block comment delimiters, "/*" and "*/", will be added automatically. + def annotate(*args) + check_if_method_has_arguments!(:annotate, args) + spawn.annotate!(*args) + end + + # Like #annotate, but modifies relation in place. + def annotate!(*args) # :nodoc: + self.annotate_values += args + self + end + # Returns the Arel object associated with the relation. def arel(aliases = nil) # :nodoc: @arel ||= build_arel(aliases) end - private - # Returns a relation value with a given name - def get_value(name) - @values.fetch(name, DEFAULT_VALUES[name]) - end + def construct_join_dependency(associations, join_type) # :nodoc: + ActiveRecord::Associations::JoinDependency.new( + klass, table, associations, join_type + ) + end - # Sets the relation value with the given name - def set_value(name, value) - assert_mutability! - @values[name] = value + protected + def build_subquery(subquery_alias, select_value) # :nodoc: + subquery = except(:optimizer_hints).arel.as(subquery_alias) + + Arel::SelectManager.new(subquery).project(select_value).tap do |arel| + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? + end end + private def assert_mutability! raise ImmutableRelation if @loaded raise ImmutableRelation if defined?(@arel) && @arel @@ -929,8 +1029,11 @@ module ActiveRecord def build_arel(aliases) arel = Arel::SelectManager.new(table) - aliases = build_joins(arel, joins_values.flatten, aliases) unless joins_values.empty? - build_left_outer_joins(arel, left_outer_joins_values.flatten, aliases) unless left_outer_joins_values.empty? + if !joins_values.empty? + build_joins(arel, joins_values.flatten, aliases) + elsif !left_outer_joins_values.empty? + build_left_outer_joins(arel, left_outer_joins_values.flatten, aliases) + end arel.where(where_clause.ast) unless where_clause.empty? arel.having(having_clause.ast) unless having_clause.empty? @@ -956,9 +1059,11 @@ module ActiveRecord build_select(arel) + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? arel.distinct(distinct_value) arel.from(build_from) unless from_clause.empty? arel.lock(lock_value) if lock_value + arel.comment(*annotate_values) unless annotate_values.empty? arel end @@ -978,22 +1083,28 @@ module ActiveRecord end end - def build_left_outer_joins(manager, outer_joins, aliases) - buckets = outer_joins.group_by do |join| - case join + def valid_association_list(associations) + associations.each do |association| + case association when Hash, Symbol, Array - :association_join - when ActiveRecord::Associations::JoinDependency - :stashed_join + # valid else raise ArgumentError, "only Hash, Symbol and Array are allowed" end end + end + def build_left_outer_joins(manager, outer_joins, aliases) + buckets = { association_join: valid_association_list(outer_joins) } build_join_query(manager, buckets, Arel::Nodes::OuterJoin, aliases) end def build_joins(manager, joins, aliases) + unless left_outer_joins_values.empty? + left_joins = valid_association_list(left_outer_joins_values.flatten) + joins.unshift construct_join_dependency(left_joins, Arel::Nodes::OuterJoin) + end + buckets = joins.group_by do |join| case join when String @@ -1023,9 +1134,9 @@ module ActiveRecord join_list = join_nodes + convert_join_strings_to_ast(string_joins) alias_tracker = alias_tracker(join_list, aliases) - join_dependency = construct_join_dependency(association_joins) + join_dependency = construct_join_dependency(association_joins, join_type) - joins = join_dependency.join_constraints(stashed_joins, join_type, alias_tracker) + joins = join_dependency.join_constraints(stashed_joins, alias_tracker) joins.each { |join| manager.from(join) } manager.join_sources.concat(join_list) @@ -1052,11 +1163,13 @@ module ActiveRecord def arel_columns(columns) columns.flat_map do |field| - if (Symbol === field || String === field) && (klass.has_attribute?(field) || klass.attribute_alias?(field)) && !from_clause.value - arel_attribute(field) - elsif Symbol === field - connection.quote_table_name(field.to_s) - elsif Proc === field + case field + when Symbol + field = field.to_s + arel_column(field, &connection.method(:quote_table_name)) + when String + arel_column(field, &:itself) + when Proc field.call else field @@ -1064,6 +1177,21 @@ module ActiveRecord end end + def arel_column(field) + field = klass.attribute_aliases[field] || field + from = from_clause.name || from_clause.value + + if klass.columns_hash.key?(field) && (!from || table_name_matches?(from)) + arel_attribute(field) + else + yield field + end + end + + def table_name_matches?(from) + /(?:\A|(?<!FROM)\s)(?:\b#{table.name}\b|#{connection.quote_table_name(table.name)})(?!\.)/i.match?(from.to_s) + end + def reverse_sql_order(order_query) if order_query.empty? return [arel_attribute(primary_key).desc] if primary_key @@ -1079,7 +1207,7 @@ module ActiveRecord o.reverse when String if does_not_support_reverse?(o) - raise IrreversibleOrderError, "Order #{o.inspect} can not be reversed automatically" + raise IrreversibleOrderError, "Order #{o.inspect} cannot be reversed automatically" end o.split(",").map! do |s| s.strip! @@ -1099,7 +1227,7 @@ module ActiveRecord # Uses SQL function with multiple arguments. (order.include?(",") && order.split(",").find { |section| section.count("(") != section.count(")") }) || # Uses "nulls first" like construction. - /nulls (first|last)\Z/i.match?(order) + /\bnulls\s+(?:first|last)\b/i.match?(order) end def build_order(arel) @@ -1145,14 +1273,20 @@ module ActiveRecord order_args.map! do |arg| case arg when Symbol - arel_attribute(arg).asc + arg = arg.to_s + arel_column(arg) { + Arel.sql(connection.quote_table_name(arg)) + }.asc when Hash arg.map { |field, dir| case field when Arel::Nodes::SqlLiteral field.send(dir.downcase) else - arel_attribute(field).send(dir.downcase) + field = field.to_s + arel_column(field) { + Arel.sql(connection.quote_table_name(field)) + }.send(dir.downcase) end } else @@ -1187,7 +1321,8 @@ module ActiveRecord def structurally_incompatible_values_for_or(other) values = other.values STRUCTURAL_OR_METHODS.reject do |method| - get_value(method) == values.fetch(method, DEFAULT_VALUES[method]) + default = DEFAULT_VALUES[method] + @values.fetch(method, default) == values.fetch(method, default) end end diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb index 8cf4fc469f..efc4b447aa 100644 --- a/activerecord/lib/active_record/relation/spawn_methods.rb +++ b/activerecord/lib/active_record/relation/spawn_methods.rb @@ -8,7 +8,7 @@ module ActiveRecord module SpawnMethods # This is overridden by Associations::CollectionProxy def spawn #:nodoc: - @delegate_to_klass && klass.current_scope(true) ? klass.all : clone + already_in_scope? ? klass.all : clone end # Merges in the conditions from <tt>other</tt>, if <tt>other</tt> is an ActiveRecord::Relation. diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index e225628bae..b91b135867 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -70,7 +70,15 @@ module ActiveRecord predicates == other.predicates end - def invert + def invert(as = :nand) + if predicates.size == 1 + inverted_predicates = [ invert_predicate(predicates.first) ] + elsif as == :nor + inverted_predicates = predicates.map { |node| invert_predicate(node) } + else + inverted_predicates = [ Arel::Nodes::Not.new(ast) ] + end + WhereClause.new(inverted_predicates) end @@ -115,10 +123,6 @@ module ActiveRecord node.respond_to?(:operator) && node.operator == :== end - def inverted_predicates - predicates.map { |node| invert_predicate(node) } - end - def invert_predicate(node) case node when NilClass @@ -140,11 +144,7 @@ module ActiveRecord def except_predicates(columns) predicates.reject do |node| - case node - when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual - subrelation = (node.left.kind_of?(Arel::Attributes::Attribute) ? node.left : node.right) - columns.include?(subrelation.name.to_s) - end + Arel.fetch_attribute(node) { |attr| columns.include?(attr.name.to_s) } end end diff --git a/activerecord/lib/active_record/sanitization.rb b/activerecord/lib/active_record/sanitization.rb index e6197752bc..750766714d 100644 --- a/activerecord/lib/active_record/sanitization.rb +++ b/activerecord/lib/active_record/sanitization.rb @@ -165,10 +165,11 @@ module ActiveRecord def quote_bound_value(value, c = connection) if value.respond_to?(:map) && !value.acts_like?(:string) - if value.respond_to?(:empty?) && value.empty? + quoted = value.map { |v| c.quote(v) } + if quoted.empty? c.quote(nil) else - value.map { |v| c.quote(v) }.join(",") + quoted.join(",") end else c.quote(value) diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index d475e77444..2f7cc07221 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -47,6 +47,7 @@ module ActiveRecord end private + attr_accessor :table_name def initialize(connection, options = {}) @connection = connection @@ -110,6 +111,8 @@ HEADER def table(table, stream) columns = @connection.columns(table) begin + self.table_name = table + tbl = StringIO.new # first dump primary key column @@ -159,6 +162,8 @@ HEADER stream.puts "# Could not dump table #{table.inspect} because of following #{e.class}" stream.puts "# #{e.message}" stream.puts + ensure + self.table_name = nil end end diff --git a/activerecord/lib/active_record/schema_migration.rb b/activerecord/lib/active_record/schema_migration.rb index 1fca1a18f6..74547de862 100644 --- a/activerecord/lib/active_record/schema_migration.rb +++ b/activerecord/lib/active_record/schema_migration.rb @@ -19,7 +19,7 @@ module ActiveRecord end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.schema_migrations_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{schema_migrations_table_name}#{table_name_suffix}" end def table_exists? diff --git a/activerecord/lib/active_record/scoping.rb b/activerecord/lib/active_record/scoping.rb index 1142a87d25..35e9dcbffc 100644 --- a/activerecord/lib/active_record/scoping.rb +++ b/activerecord/lib/active_record/scoping.rb @@ -30,14 +30,6 @@ module ActiveRecord def current_scope=(scope) ScopeRegistry.set_value_for(:current_scope, self, scope) end - - def current_scope_restoring_block(&block) - current_scope = self.current_scope(true) - -> *args do - self.current_scope = current_scope - yield(*args) if block_given? - end - end end def populate_with_current_scope_attributes # :nodoc: diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 8c612df27a..87bcfd5181 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -86,8 +86,8 @@ module ActiveRecord # # Should return a scope, you can call 'super' here etc. # end # end - def default_scope(scope = nil) # :doc: - scope = Proc.new if block_given? + def default_scope(scope = nil, &block) # :doc: + scope = block if block_given? if scope.is_a?(Relation) || !scope.respond_to?(:call) raise ArgumentError, @@ -100,7 +100,7 @@ module ActiveRecord self.default_scopes += [scope] end - def build_default_scope(base_rel = nil) + def build_default_scope(relation = relation()) return if abstract_class? if default_scope_override.nil? @@ -111,15 +111,14 @@ module ActiveRecord # The user has defined their own default scope method, so call that evaluate_default_scope do if scope = default_scope - (base_rel ||= relation).merge!(scope) + relation.merge!(scope) end end elsif default_scopes.any? - base_rel ||= relation evaluate_default_scope do - default_scopes.inject(base_rel) do |default_scope, scope| + default_scopes.inject(relation) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge!(base_rel.instance_exec(&scope)) + default_scope.instance_exec(&scope) || default_scope end end end diff --git a/activerecord/lib/active_record/scoping/named.rb b/activerecord/lib/active_record/scoping/named.rb index 5278eb29a9..cd9801b7a0 100644 --- a/activerecord/lib/active_record/scoping/named.rb +++ b/activerecord/lib/active_record/scoping/named.rb @@ -27,6 +27,14 @@ module ActiveRecord scope = current_scope if scope + if scope._deprecated_scope_source + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Class level methods will no longer inherit scoping from `#{scope._deprecated_scope_source}` + in Rails 6.1. To continue using the scoped relation, pass it into the block directly. + To instead access the full set of models, as Rails 6.1 will, use `#{name}.unscoped`. + MSG + end + if self == scope.klass scope.clone else @@ -50,7 +58,7 @@ module ActiveRecord end def default_extensions # :nodoc: - if scope = current_scope || build_default_scope + if scope = scope_for_association || build_default_scope scope.extensions else [] @@ -180,7 +188,7 @@ module ActiveRecord if body.respond_to?(:to_proc) singleton_class.define_method(name) do |*args| - scope = all._exec_scope(*args, &body) + scope = all._exec_scope(name, *args, &body) scope = scope.extending(extension) if extension scope end diff --git a/activerecord/lib/active_record/statement_cache.rb b/activerecord/lib/active_record/statement_cache.rb index 95984e7ada..93bce15230 100644 --- a/activerecord/lib/active_record/statement_cache.rb +++ b/activerecord/lib/active_record/statement_cache.rb @@ -113,8 +113,8 @@ module ActiveRecord end end - def self.create(connection, block = Proc.new) - relation = block.call Params.new + def self.create(connection, callable = nil, &block) + relation = (callable || block).call Params.new query_builder, binds = connection.cacheable_query(self, relation.arel) bind_map = BindMap.new(binds) new(query_builder, bind_map, relation.klass) diff --git a/activerecord/lib/active_record/store.rb b/activerecord/lib/active_record/store.rb index 3537e2d008..6fecb06897 100644 --- a/activerecord/lib/active_record/store.rb +++ b/activerecord/lib/active_record/store.rb @@ -11,6 +11,12 @@ module ActiveRecord # of the model. This is very helpful for easily exposing store keys to a form or elsewhere that's # already built around just accessing attributes on the model. # + # Every accessor comes with dirty tracking methods (+key_changed?+, +key_was+ and +key_change+) and + # methods to access the changes made during the last save (+saved_change_to_key?+, +saved_change_to_key+ and + # +key_before_last_save+). + # + # NOTE: There is no +key_will_change!+ method for accessors, use +store_will_change!+ instead. + # # Make sure that you declare the database column used for the serialized store as a text, so there's # plenty of room. # @@ -49,6 +55,12 @@ module ActiveRecord # u.settings[:country] # => 'Denmark' # u.settings['country'] # => 'Denmark' # + # # Dirty tracking + # u.color = 'green' + # u.color_changed? # => true + # u.color_was # => 'black' + # u.color_change # => ['black', 'red'] + # # # Add additional accessors to an existing store through store_accessor # class SuperUser < User # store_accessor :settings, :privileges, :servants @@ -127,6 +139,42 @@ module ActiveRecord define_method(accessor_key) do read_store_attribute(store_attribute, key) end + + define_method("#{accessor_key}_changed?") do + return false unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("#{accessor_key}_change") do + return unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_was") do + return unless attribute_changed?(store_attribute) + prev_store, _new_store = changes[store_attribute] + prev_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}?") do + return false unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_before_last_save") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, _new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) + end end end diff --git a/activerecord/lib/active_record/table_metadata.rb b/activerecord/lib/active_record/table_metadata.rb index b67479fb6a..073866b894 100644 --- a/activerecord/lib/active_record/table_metadata.rb +++ b/activerecord/lib/active_record/table_metadata.rb @@ -12,9 +12,9 @@ module ActiveRecord def resolve_column_aliases(hash) new_hash = hash.dup - hash.each do |key, _| - if (key.is_a?(Symbol)) && klass.attribute_alias?(key) - new_hash[klass.attribute_alias(key)] = new_hash.delete(key) + hash.each_key do |key| + if key.is_a?(Symbol) && new_key = klass.attribute_aliases[key.to_s] + new_hash[new_key] = new_hash.delete(key) end end new_hash diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 27e401a756..c79ed8db60 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -142,7 +142,9 @@ module ActiveRecord end def for_each - databases = Rails.application.config.database_configuration + return {} unless defined?(Rails) + + databases = Rails.application.config.load_database_yaml database_configs = ActiveRecord::DatabaseConfigurations.new(databases).configs_for(env_name: Rails.env) # if this is a single database application we don't want tasks for each primary database @@ -153,6 +155,20 @@ module ActiveRecord end end + def raise_for_multi_db(environment = env, command:) + db_configs = ActiveRecord::Base.configurations.configs_for(env_name: environment) + + if db_configs.count > 1 + dbs_list = [] + + db_configs.each do |db| + dbs_list << "#{command}:#{db.spec_name}" + end + + raise "You're using a multiple database application. To use `#{command}` you must run the namespaced task with a VERSION. Available tasks are #{dbs_list.to_sentence}." + end + end + def create_current(environment = env) each_current_configuration(environment) { |configuration| create configuration @@ -182,6 +198,25 @@ module ActiveRecord } end + def truncate_tables(configuration) + ActiveRecord::Base.connected_to(database: { truncation: configuration }) do + table_names = ActiveRecord::Base.connection.tables + table_names -= [ + SchemaMigration.table_name, + InternalMetadata.table_name + ] + + ActiveRecord::Base.connection.truncate_tables(*table_names) + end + end + private :truncate_tables + + def truncate_all(environment = env) + ActiveRecord::Base.configurations.configs_for(env_name: environment).each do |db_config| + truncate_tables db_config.config + end + end + def migrate check_target_version diff --git a/activerecord/lib/active_record/timestamp.rb b/activerecord/lib/active_record/timestamp.rb index 2345db7138..04a1c03474 100644 --- a/activerecord/lib/active_record/timestamp.rb +++ b/activerecord/lib/active_record/timestamp.rb @@ -101,8 +101,8 @@ module ActiveRecord super end - def _update_record(*args, touch: true, **options) - if touch && should_record_timestamps? + def _update_record + if @_touch_record && should_record_timestamps? current_time = current_time_from_proper_timezone timestamp_attributes_for_update_in_model.each do |column| @@ -110,7 +110,13 @@ module ActiveRecord _write_attribute(column, current_time) end end - super(*args) + + super + end + + def create_or_update(touch: true, **) + @_touch_record = touch + super end def should_record_timestamps? diff --git a/activerecord/lib/active_record/touch_later.rb b/activerecord/lib/active_record/touch_later.rb index f70b7c50a2..6872b7844e 100644 --- a/activerecord/lib/active_record/touch_later.rb +++ b/activerecord/lib/active_record/touch_later.rb @@ -2,7 +2,7 @@ module ActiveRecord # = Active Record Touch Later - module TouchLater + module TouchLater # :nodoc: extend ActiveSupport::Concern included do @@ -10,19 +10,15 @@ module ActiveRecord end def touch_later(*names) # :nodoc: - unless persisted? - raise ActiveRecordError, <<-MSG.squish - cannot touch on a new or destroyed record object. Consider using - persisted?, new_record?, or destroyed? before touching - MSG - end + _raise_record_not_touched_error unless persisted? @_defer_touch_attrs ||= timestamp_attributes_for_update_in_model @_defer_touch_attrs |= names @_touch_time = current_time_from_proper_timezone surreptitiously_touch @_defer_touch_attrs - self.class.connection.add_transaction_record self + add_to_transaction + @_new_record_before_last_commit ||= false # touch the parents as we are not calling the after_save callbacks self.class.reflect_on_all_associations(:belongs_to).each do |r| diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index fe3842b905..148bc0550c 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -234,6 +234,12 @@ module ActiveRecord set_callback(:commit, :after, *args, &block) end + # Shortcut for <tt>after_commit :hook, on: [ :create, :update ]</tt>. + def after_save_commit(*args, &block) + set_options_for_callbacks!(args, on: [ :create, :update ]) + set_callback(:commit, :after, *args, &block) + end + # Shortcut for <tt>after_commit :hook, on: :create</tt>. def after_create_commit(*args, &block) set_options_for_callbacks!(args, on: :create) @@ -327,7 +333,7 @@ module ActiveRecord # Ensure that it is not called if the object was never persisted (failed create), # but call it after the commit of a destroyed object. def committed!(should_run_callbacks: true) #:nodoc: - if should_run_callbacks && (destroyed? || persisted?) + if should_run_callbacks @_committed_already_called = true _run_commit_without_transaction_enrollment_callbacks _run_commit_callbacks @@ -349,18 +355,6 @@ module ActiveRecord clear_transaction_record_state end - # Add the record to the current transaction so that the #after_rollback and #after_commit callbacks - # can be called. - def add_to_transaction - if has_transactional_callbacks? - self.class.connection.add_transaction_record(self) - else - sync_with_transaction_state - set_transaction_state(self.class.connection.transaction_state) - end - remember_transaction_record_state - end - # Executes +method+ within a transaction and captures its return value as a # status flag. If the status is true the transaction is committed, otherwise # a ROLLBACK is issued. In any case the status flag is returned. @@ -370,29 +364,40 @@ module ActiveRecord def with_transaction_returning_status status = nil self.class.transaction do - add_to_transaction + if has_transactional_callbacks? + add_to_transaction + else + sync_with_transaction_state if @transaction_state&.finalized? + @transaction_state = self.class.connection.transaction_state + end + remember_transaction_record_state + status = yield raise ActiveRecord::Rollback unless status end status end + def trigger_transactional_callbacks? # :nodoc: + (@_new_record_before_last_commit || _trigger_update_callback) && persisted? || + _trigger_destroy_callback && destroyed? + end + private attr_reader :_committed_already_called, :_trigger_update_callback, :_trigger_destroy_callback # Save the new record state and id of a record so it can be restored later if a transaction fails. def remember_transaction_record_state - @_start_transaction_state.reverse_merge!( + @_start_transaction_state ||= { id: id, new_record: @new_record, destroyed: @destroyed, + attributes: @attributes, frozen?: frozen?, - ) - @_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) + 1 - remember_new_record_before_last_commit - end + level: 0 + } + @_start_transaction_state[:level] += 1 - def remember_new_record_before_last_commit if _committed_already_called @_new_record_before_last_commit = false else @@ -402,27 +407,32 @@ module ActiveRecord # Clear the new record state and id of a record. def clear_transaction_record_state - @_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) - 1 + return unless @_start_transaction_state + @_start_transaction_state[:level] -= 1 force_clear_transaction_record_state if @_start_transaction_state[:level] < 1 end # Force to clear the transaction record state. def force_clear_transaction_record_state - @_start_transaction_state.clear + @_start_transaction_state = nil + @transaction_state = nil end # Restore the new record state and id of a record that was previously saved by a call to save_record_state. - def restore_transaction_record_state(force = false) - unless @_start_transaction_state.empty? - transaction_level = (@_start_transaction_state[:level] || 0) - 1 - if transaction_level < 1 || force - restore_state = @_start_transaction_state - thaw + def restore_transaction_record_state(force_restore_state = false) + if restore_state = @_start_transaction_state + if force_restore_state || restore_state[:level] <= 1 @new_record = restore_state[:new_record] @destroyed = restore_state[:destroyed] - pk = self.class.primary_key - if pk && _read_attribute(pk) != restore_state[:id] - _write_attribute(pk, restore_state[:id]) + @attributes = restore_state[:attributes].map do |attr| + value = @attributes.fetch_value(attr.name) + attr = attr.with_value_from_user(value) if attr.value != value + attr + end + @mutations_from_database = nil + @mutations_before_last_save = nil + if @attributes.fetch_value(@primary_key) != restore_state[:id] + @attributes.write_from_user(@primary_key, restore_state[:id]) end freeze if restore_state[:frozen?] end @@ -443,8 +453,10 @@ module ActiveRecord end end - def set_transaction_state(state) - @transaction_state = state + # Add the record to the current transaction so that the #after_rollback and #after_commit + # callbacks can be called. + def add_to_transaction + self.class.connection.add_transaction_record(self) end def has_transactional_callbacks? @@ -464,19 +476,17 @@ module ActiveRecord # This method checks to see if the ActiveRecord object's state reflects # the TransactionState, and rolls back or commits the Active Record object # as appropriate. - # - # Since Active Record objects can be inside multiple transactions, this - # method recursively goes through the parent of the TransactionState and - # checks if the Active Record object reflects the state of the object. def sync_with_transaction_state - update_attributes_from_transaction_state(@transaction_state) - end - - def update_attributes_from_transaction_state(transaction_state) - if transaction_state && transaction_state.finalized? - restore_transaction_record_state(transaction_state.fully_rolledback?) if transaction_state.rolledback? - force_clear_transaction_record_state if transaction_state.fully_committed? - clear_transaction_record_state if transaction_state.fully_completed? + if transaction_state = @transaction_state + if transaction_state.fully_committed? + force_clear_transaction_record_state + elsif transaction_state.committed? + clear_transaction_record_state + elsif transaction_state.rolledback? + force_restore_state = transaction_state.fully_rolledback? + restore_transaction_record_state(force_restore_state) + clear_transaction_record_state + end end end end diff --git a/activerecord/lib/active_record/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb index fb745af125..2c3a2fb797 100644 --- a/activerecord/lib/active_record/validations/uniqueness.rb +++ b/activerecord/lib/active_record/validations/uniqueness.rb @@ -12,7 +12,7 @@ module ActiveRecord raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \ "Pass a symbol or an array of symbols instead: `scope: :user_id`" end - super({ case_sensitive: true }.merge!(options)) + super @klass = options[:class] end @@ -25,7 +25,7 @@ module ActiveRecord if finder_class.primary_key relation = relation.where.not(finder_class.primary_key => record.id_in_database) else - raise UnknownPrimaryKey.new(finder_class, "Can not validate uniqueness for persisted record without primary key.") + raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.") end end relation = scope_relation(record, relation) @@ -60,8 +60,8 @@ module ActiveRecord comparison = relation.bind_attribute(attribute, value) do |attr, bind| return relation.none! if bind.unboundable? - if bind.nil? - attr.eq(bind) + if !options.key?(:case_sensitive) || bind.nil? + klass.connection.default_uniqueness_comparison(attr, bind, klass) elsif options[:case_sensitive] klass.connection.case_sensitive_comparison(attr, bind) else diff --git a/activerecord/lib/arel.rb b/activerecord/lib/arel.rb index 7411b5c41b..361cd915cc 100644 --- a/activerecord/lib/arel.rb +++ b/activerecord/lib/arel.rb @@ -39,6 +39,13 @@ module Arel # :nodoc: all value.is_a?(Arel::Node) || value.is_a?(Arel::Attribute) || value.is_a?(Arel::Nodes::SqlLiteral) end + def self.fetch_attribute(value) + case value + when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual + yield value.left.is_a?(Arel::Attributes::Attribute) ? value.left : value.right + end + end + ## Convenience Alias Node = Arel::Nodes::Node end diff --git a/activerecord/lib/arel/insert_manager.rb b/activerecord/lib/arel/insert_manager.rb index c90fc33a48..cb31e3060b 100644 --- a/activerecord/lib/arel/insert_manager.rb +++ b/activerecord/lib/arel/insert_manager.rb @@ -33,13 +33,13 @@ module Arel # :nodoc: all @ast.columns << column values << value end - @ast.values = create_values values, @ast.columns + @ast.values = create_values(values) end self end - def create_values(values, columns) - Nodes::Values.new values, columns + def create_values(values) + Nodes::ValuesList.new([values]) end def create_values_list(rows) diff --git a/activerecord/lib/arel/nodes.rb b/activerecord/lib/arel/nodes.rb index 5af0e532e2..f994754620 100644 --- a/activerecord/lib/arel/nodes.rb +++ b/activerecord/lib/arel/nodes.rb @@ -45,7 +45,6 @@ require "arel/nodes/and" require "arel/nodes/function" require "arel/nodes/count" require "arel/nodes/extract" -require "arel/nodes/values" require "arel/nodes/values_list" require "arel/nodes/named_function" @@ -62,6 +61,8 @@ require "arel/nodes/outer_join" require "arel/nodes/right_outer_join" require "arel/nodes/string_join" +require "arel/nodes/comment" + require "arel/nodes/sql_literal" require "arel/nodes/casted" diff --git a/activerecord/lib/arel/nodes/comment.rb b/activerecord/lib/arel/nodes/comment.rb new file mode 100644 index 0000000000..237ff27e7e --- /dev/null +++ b/activerecord/lib/arel/nodes/comment.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Comment < Arel::Nodes::Node + attr_reader :values + + def initialize(values) + super() + @values = values + end + + def initialize_copy(other) + super + @values = @values.clone + end + + def hash + [@values].hash + end + + def eql?(other) + self.class == other.class && + self.values == other.values + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/select_core.rb b/activerecord/lib/arel/nodes/select_core.rb index 73461ff683..11b4f39ece 100644 --- a/activerecord/lib/arel/nodes/select_core.rb +++ b/activerecord/lib/arel/nodes/select_core.rb @@ -3,20 +3,22 @@ module Arel # :nodoc: all module Nodes class SelectCore < Arel::Nodes::Node - attr_accessor :projections, :wheres, :groups, :windows - attr_accessor :havings, :source, :set_quantifier + attr_accessor :projections, :wheres, :groups, :windows, :comment + attr_accessor :havings, :source, :set_quantifier, :optimizer_hints def initialize super() - @source = JoinSource.new nil + @source = JoinSource.new nil # https://ronsavage.github.io/SQL/sql-92.bnf.html#set%20quantifier - @set_quantifier = nil - @projections = [] - @wheres = [] - @groups = [] - @havings = [] - @windows = [] + @set_quantifier = nil + @optimizer_hints = nil + @projections = [] + @wheres = [] + @groups = [] + @havings = [] + @windows = [] + @comment = nil end def from @@ -42,8 +44,8 @@ module Arel # :nodoc: all def hash [ - @source, @set_quantifier, @projections, - @wheres, @groups, @havings, @windows + @source, @set_quantifier, @projections, @optimizer_hints, + @wheres, @groups, @havings, @windows, @comment ].hash end @@ -51,11 +53,13 @@ module Arel # :nodoc: all self.class == other.class && self.source == other.source && self.set_quantifier == other.set_quantifier && + self.optimizer_hints == other.optimizer_hints && self.projections == other.projections && self.wheres == other.wheres && self.groups == other.groups && self.havings == other.havings && - self.windows == other.windows + self.windows == other.windows && + self.comment == other.comment end alias :== :eql? end diff --git a/activerecord/lib/arel/nodes/unary.rb b/activerecord/lib/arel/nodes/unary.rb index 00639304e4..6d1ac36b0e 100644 --- a/activerecord/lib/arel/nodes/unary.rb +++ b/activerecord/lib/arel/nodes/unary.rb @@ -35,6 +35,7 @@ module Arel # :nodoc: all Not Offset On + OptimizerHints Ordering RollUp }.each do |name| diff --git a/activerecord/lib/arel/nodes/values.rb b/activerecord/lib/arel/nodes/values.rb deleted file mode 100644 index 650248dc04..0000000000 --- a/activerecord/lib/arel/nodes/values.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Arel # :nodoc: all - module Nodes - class Values < Arel::Nodes::Binary - alias :expressions :left - alias :expressions= :left= - alias :columns :right - alias :columns= :right= - - def initialize(exprs, columns = []) - super - end - end - end -end diff --git a/activerecord/lib/arel/nodes/values_list.rb b/activerecord/lib/arel/nodes/values_list.rb index 27109848e4..1a9d9ebf01 100644 --- a/activerecord/lib/arel/nodes/values_list.rb +++ b/activerecord/lib/arel/nodes/values_list.rb @@ -2,23 +2,8 @@ module Arel # :nodoc: all module Nodes - class ValuesList < Node - attr_reader :rows - - def initialize(rows) - @rows = rows - super() - end - - def hash - @rows.hash - end - - def eql?(other) - self.class == other.class && - self.rows == other.rows - end - alias :== :eql? + class ValuesList < Unary + alias :rows :expr end end end diff --git a/activerecord/lib/arel/select_manager.rb b/activerecord/lib/arel/select_manager.rb index a2b2838a3d..ddc9e394dd 100644 --- a/activerecord/lib/arel/select_manager.rb +++ b/activerecord/lib/arel/select_manager.rb @@ -146,6 +146,13 @@ module Arel # :nodoc: all @ctx.projections = projections end + def optimizer_hints(*hints) + unless hints.empty? + @ctx.optimizer_hints = Arel::Nodes::OptimizerHints.new(hints) + end + self + end + def distinct(value = true) if value @ctx.set_quantifier = Arel::Nodes::Distinct.new @@ -237,16 +244,9 @@ module Arel # :nodoc: all @ctx.source end - class Row < Struct.new(:data) # :nodoc: - def id - data["id"] - end - - def method_missing(name, *args) - name = name.to_s - return data[name] if data.key?(name) - super - end + def comment(*values) + @ctx.comment = Nodes::Comment.new(values) + self end private diff --git a/activerecord/lib/arel/visitors/depth_first.rb b/activerecord/lib/arel/visitors/depth_first.rb index 92d309453c..d696edc507 100644 --- a/activerecord/lib/arel/visitors/depth_first.rb +++ b/activerecord/lib/arel/visitors/depth_first.rb @@ -35,6 +35,8 @@ module Arel # :nodoc: all alias :visit_Arel_Nodes_Ascending :unary alias :visit_Arel_Nodes_Descending :unary alias :visit_Arel_Nodes_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_OptimizerHints :unary + alias :visit_Arel_Nodes_ValuesList :unary def function(o) visit o.expressions @@ -102,7 +104,6 @@ module Arel # :nodoc: all alias :visit_Arel_Nodes_Regexp :binary alias :visit_Arel_Nodes_RightOuterJoin :binary alias :visit_Arel_Nodes_TableAlias :binary - alias :visit_Arel_Nodes_Values :binary alias :visit_Arel_Nodes_When :binary def visit_Arel_Nodes_StringJoin(o) @@ -180,6 +181,10 @@ module Arel # :nodoc: all visit o.limit end + def visit_Arel_Nodes_Comment(o) + visit o.values + end + def visit_Array(o) o.each { |i| visit i } end diff --git a/activerecord/lib/arel/visitors/dot.rb b/activerecord/lib/arel/visitors/dot.rb index 6389c875cb..ecc386de07 100644 --- a/activerecord/lib/arel/visitors/dot.rb +++ b/activerecord/lib/arel/visitors/dot.rb @@ -46,8 +46,8 @@ module Arel # :nodoc: all visit_edge o, "distinct" end - def visit_Arel_Nodes_Values(o) - visit_edge o, "expressions" + def visit_Arel_Nodes_ValuesList(o) + visit_edge o, "rows" end def visit_Arel_Nodes_StringJoin(o) @@ -82,6 +82,7 @@ module Arel # :nodoc: all alias :visit_Arel_Nodes_Offset :unary alias :visit_Arel_Nodes_On :unary alias :visit_Arel_Nodes_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_OptimizerHints :unary alias :visit_Arel_Nodes_Preceding :unary alias :visit_Arel_Nodes_Following :unary alias :visit_Arel_Nodes_Rows :unary @@ -233,6 +234,10 @@ module Arel # :nodoc: all end alias :visit_Set :visit_Array + def visit_Arel_Nodes_Comment(o) + visit_edge(o, "values") + end + def visit_edge(o, method) edge(method) { visit o.send(method) } end diff --git a/activerecord/lib/arel/visitors/ibm_db.rb b/activerecord/lib/arel/visitors/ibm_db.rb index 73166054da..5cf958f5f0 100644 --- a/activerecord/lib/arel/visitors/ibm_db.rb +++ b/activerecord/lib/arel/visitors/ibm_db.rb @@ -4,6 +4,15 @@ module Arel # :nodoc: all module Visitors class IBM_DB < Arel::Visitors::ToSql private + def visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join + collector << "/* <OPTGUIDELINES>#{hints}</OPTGUIDELINES> */" + end def visit_Arel_Nodes_Limit(o, collector) collector << "FETCH FIRST " @@ -16,6 +25,10 @@ module Arel # :nodoc: all collector = visit [o.left, o.right, 0, 1], collector collector << ")" end + + def collect_optimizer_hints(o, collector) + collector + end end end end diff --git a/activerecord/lib/arel/visitors/informix.rb b/activerecord/lib/arel/visitors/informix.rb index 208fa15aef..1a4ad1c8d8 100644 --- a/activerecord/lib/arel/visitors/informix.rb +++ b/activerecord/lib/arel/visitors/informix.rb @@ -42,10 +42,16 @@ module Arel # :nodoc: all collector end + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join(", ") + collector << "/*+ #{hints} */" + end + def visit_Arel_Nodes_Offset(o, collector) collector << "SKIP " visit o.expr, collector end + def visit_Arel_Nodes_Limit(o, collector) collector << "FIRST " visit o.expr, collector diff --git a/activerecord/lib/arel/visitors/mssql.rb b/activerecord/lib/arel/visitors/mssql.rb index fdd864b40d..8475139870 100644 --- a/activerecord/lib/arel/visitors/mssql.rb +++ b/activerecord/lib/arel/visitors/mssql.rb @@ -76,6 +76,16 @@ module Arel # :nodoc: all end end + def visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join(", ") + collector << "OPTION (#{hints})" + end + def get_offset_limit_clause(o) first_row = o.offset ? o.offset.expr.to_i + 1 : 1 last_row = o.limit ? o.limit.expr.to_i - 1 + first_row : nil @@ -97,12 +107,16 @@ module Arel # :nodoc: all collector = visit o.relation, collector if o.wheres.any? collector << " WHERE " - inject_join o.wheres, collector, AND + inject_join o.wheres, collector, " AND " else collector end end + def collect_optimizer_hints(o, collector) + collector + end + def determine_order_by(orders, x) if orders.any? orders diff --git a/activerecord/lib/arel/visitors/oracle12.rb b/activerecord/lib/arel/visitors/oracle12.rb index 9a7fe4d626..6269bc3907 100644 --- a/activerecord/lib/arel/visitors/oracle12.rb +++ b/activerecord/lib/arel/visitors/oracle12.rb @@ -8,11 +8,10 @@ module Arel # :nodoc: all def visit_Arel_Nodes_SelectStatement(o, collector) # Oracle does not allow LIMIT clause with select for update if o.limit && o.lock - raise ArgumentError, <<-MSG - 'Combination of limit and lock is not supported. - because generated SQL statements - `SELECT FOR UPDATE and FETCH FIRST n ROWS` generates ORA-02014.` - MSG + raise ArgumentError, <<~MSG + Combination of limit and lock is not supported. Because generated SQL statements + `SELECT FOR UPDATE and FETCH FIRST n ROWS` generates ORA-02014. + MSG end super end diff --git a/activerecord/lib/arel/visitors/postgresql.rb b/activerecord/lib/arel/visitors/postgresql.rb index 920776b4dc..8296f1cdc1 100644 --- a/activerecord/lib/arel/visitors/postgresql.rb +++ b/activerecord/lib/arel/visitors/postgresql.rb @@ -3,11 +3,6 @@ module Arel # :nodoc: all module Visitors class PostgreSQL < Arel::Visitors::ToSql - CUBE = "CUBE" - ROLLUP = "ROLLUP" - GROUPING_SETS = "GROUPING SETS" - LATERAL = "LATERAL" - private def visit_Arel_Nodes_Matches(o, collector) @@ -57,23 +52,22 @@ module Arel # :nodoc: all end def visit_Arel_Nodes_Cube(o, collector) - collector << CUBE + collector << "CUBE" grouping_array_or_grouping_element o, collector end def visit_Arel_Nodes_RollUp(o, collector) - collector << ROLLUP + collector << "ROLLUP" grouping_array_or_grouping_element o, collector end def visit_Arel_Nodes_GroupingSet(o, collector) - collector << GROUPING_SETS + collector << "GROUPING SETS" grouping_array_or_grouping_element o, collector end def visit_Arel_Nodes_Lateral(o, collector) - collector << LATERAL - collector << SPACE + collector << "LATERAL " grouping_parentheses o, collector end diff --git a/activerecord/lib/arel/visitors/to_sql.rb b/activerecord/lib/arel/visitors/to_sql.rb index d0dec63860..4740e6d94f 100644 --- a/activerecord/lib/arel/visitors/to_sql.rb +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -9,59 +9,6 @@ module Arel # :nodoc: all end class ToSql < Arel::Visitors::Visitor - ## - # This is some roflscale crazy stuff. I'm roflscaling this because - # building SQL queries is a hotspot. I will explain the roflscale so that - # others will not rm this code. - # - # In YARV, string literals in a method body will get duped when the byte - # code is executed. Let's take a look: - # - # > puts RubyVM::InstructionSequence.new('def foo; "bar"; end').disasm - # - # == disasm: <RubyVM::InstructionSequence:foo@<compiled>>===== - # 0000 trace 8 - # 0002 trace 1 - # 0004 putstring "bar" - # 0006 trace 16 - # 0008 leave - # - # The `putstring` bytecode will dup the string and push it on the stack. - # In many cases in our SQL visitor, that string is never mutated, so there - # is no need to dup the literal. - # - # If we change to a constant lookup, the string will not be duped, and we - # can reduce the objects in our system: - # - # > puts RubyVM::InstructionSequence.new('BAR = "bar"; def foo; BAR; end').disasm - # - # == disasm: <RubyVM::InstructionSequence:foo@<compiled>>======== - # 0000 trace 8 - # 0002 trace 1 - # 0004 getinlinecache 11, <ic:0> - # 0007 getconstant :BAR - # 0009 setinlinecache <ic:0> - # 0011 trace 16 - # 0013 leave - # - # `getconstant` should be a hash lookup, and no object is duped when the - # value of the constant is pushed on the stack. Hence the crazy - # constants below. - # - # `matches` and `doesNotMatch` operate case-insensitively via Visitor subclasses - # specialized for specific databases when necessary. - # - - WHERE = " WHERE " # :nodoc: - SPACE = " " # :nodoc: - COMMA = ", " # :nodoc: - GROUP_BY = " GROUP BY " # :nodoc: - ORDER_BY = " ORDER BY " # :nodoc: - WINDOW = " WINDOW " # :nodoc: - AND = " AND " # :nodoc: - - DISTINCT = "DISTINCT" # :nodoc: - def initialize(connection) super() @connection = connection @@ -159,39 +106,20 @@ module Arel # :nodoc: all when Nodes::SqlLiteral, Nodes::BindParam collector = visit(value, collector) else - collector << quote(value) + collector << quote(value).to_s end - collector << COMMA unless k == row_len + collector << ", " unless k == row_len end collector << ")" - collector << COMMA unless i == len + collector << ", " unless i == len } collector end - def visit_Arel_Nodes_Values(o, collector) - collector << "VALUES (" - - len = o.expressions.length - 1 - o.expressions.each_with_index { |value, i| - case value - when Nodes::SqlLiteral, Nodes::BindParam - collector = visit value, collector - else - collector << quote(value).to_s - end - unless i == len - collector << COMMA - end - } - - collector << ")" - end - def visit_Arel_Nodes_SelectStatement(o, collector) if o.with collector = visit o.with, collector - collector << SPACE + collector << " " end collector = o.cores.inject(collector) { |c, x| @@ -199,11 +127,11 @@ module Arel # :nodoc: all } unless o.orders.empty? - collector << ORDER_BY + collector << " ORDER BY " len = o.orders.length - 1 o.orders.each_with_index { |x, i| collector = visit(x, collector) - collector << COMMA unless len == i + collector << ", " unless len == i } end @@ -219,24 +147,34 @@ module Arel # :nodoc: all def visit_Arel_Nodes_SelectCore(o, collector) collector << "SELECT" + collector = collect_optimizer_hints(o, collector) collector = maybe_visit o.set_quantifier, collector - collect_nodes_for o.projections, collector, SPACE + collect_nodes_for o.projections, collector, " " if o.source && !o.source.empty? collector << " FROM " collector = visit o.source, collector end - collect_nodes_for o.wheres, collector, WHERE, AND - collect_nodes_for o.groups, collector, GROUP_BY - collect_nodes_for o.havings, collector, " HAVING ", AND - collect_nodes_for o.windows, collector, WINDOW + collect_nodes_for o.wheres, collector, " WHERE ", " AND " + collect_nodes_for o.groups, collector, " GROUP BY " + collect_nodes_for o.havings, collector, " HAVING ", " AND " + collect_nodes_for o.windows, collector, " WINDOW " - collector + maybe_visit o.comment, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join(" ") + collector << "/*+ #{hints} */" end - def collect_nodes_for(nodes, collector, spacer, connector = COMMA) + def visit_Arel_Nodes_Comment(o, collector) + collector << o.values.map { |v| "/* #{sanitize_as_sql_comment(v)} */" }.join(" ") + end + + def collect_nodes_for(nodes, collector, spacer, connector = ", ") unless nodes.empty? collector << spacer inject_join nodes, collector, connector @@ -248,7 +186,7 @@ module Arel # :nodoc: all end def visit_Arel_Nodes_Distinct(o, collector) - collector << DISTINCT + collector << "DISTINCT" end def visit_Arel_Nodes_DistinctOn(o, collector) @@ -257,12 +195,12 @@ module Arel # :nodoc: all def visit_Arel_Nodes_With(o, collector) collector << "WITH " - inject_join o.children, collector, COMMA + inject_join o.children, collector, ", " end def visit_Arel_Nodes_WithRecursive(o, collector) collector << "WITH RECURSIVE " - inject_join o.children, collector, COMMA + inject_join o.children, collector, ", " end def visit_Arel_Nodes_Union(o, collector) @@ -295,13 +233,13 @@ module Arel # :nodoc: all collect_nodes_for o.partitions, collector, "PARTITION BY " if o.orders.any? - collector << SPACE if o.partitions.any? + collector << " " if o.partitions.any? collector << "ORDER BY " collector = inject_join o.orders, collector, ", " end if o.framing - collector << SPACE if o.partitions.any? || o.orders.any? + collector << " " if o.partitions.any? || o.orders.any? collector = visit o.framing, collector end @@ -506,8 +444,8 @@ module Arel # :nodoc: all collector = visit o.left, collector end if o.right.any? - collector << SPACE if o.left - collector = inject_join o.right, collector, SPACE + collector << " " if o.left + collector = inject_join o.right, collector, " " end collector end @@ -527,7 +465,7 @@ module Arel # :nodoc: all def visit_Arel_Nodes_FullOuterJoin(o, collector) collector << "FULL OUTER JOIN " collector = visit o.left, collector - collector << SPACE + collector << " " visit o.right, collector end @@ -541,7 +479,7 @@ module Arel # :nodoc: all def visit_Arel_Nodes_RightOuterJoin(o, collector) collector << "RIGHT OUTER JOIN " collector = visit o.left, collector - collector << SPACE + collector << " " visit o.right, collector end @@ -549,7 +487,7 @@ module Arel # :nodoc: all collector << "INNER JOIN " collector = visit o.left, collector if o.right - collector << SPACE + collector << " " visit(o.right, collector) else collector @@ -575,34 +513,66 @@ module Arel # :nodoc: all end def visit_Arel_Nodes_In(o, collector) - if Array === o.right && !o.right.empty? + unless Array === o.right + return collect_in_clause(o.left, o.right, collector) + end + + unless o.right.empty? o.right.delete_if { |value| unboundable?(value) } end - if Array === o.right && o.right.empty? - collector << "1=0" + return collector << "1=0" if o.right.empty? + + in_clause_length = @connection.in_clause_length + + if !in_clause_length || o.right.length <= in_clause_length + collect_in_clause(o.left, o.right, collector) else - collector = visit o.left, collector - collector << " IN (" - visit(o.right, collector) << ")" + collector << "(" + o.right.each_slice(in_clause_length).each_with_index do |right, i| + collector << " OR " unless i == 0 + collect_in_clause(o.left, right, collector) + end + collector << ")" end end + def collect_in_clause(left, right, collector) + collector = visit left, collector + collector << " IN (" + visit(right, collector) << ")" + end + def visit_Arel_Nodes_NotIn(o, collector) - if Array === o.right && !o.right.empty? + unless Array === o.right + return collect_not_in_clause(o.left, o.right, collector) + end + + unless o.right.empty? o.right.delete_if { |value| unboundable?(value) } end - if Array === o.right && o.right.empty? - collector << "1=1" + return collector << "1=1" if o.right.empty? + + in_clause_length = @connection.in_clause_length + + if !in_clause_length || o.right.length <= in_clause_length + collect_not_in_clause(o.left, o.right, collector) else - collector = visit o.left, collector - collector << " NOT IN (" - collector = visit o.right, collector - collector << ")" + o.right.each_slice(in_clause_length).each_with_index do |right, i| + collector << " AND " unless i == 0 + collect_not_in_clause(o.left, right, collector) + end + collector end end + def collect_not_in_clause(left, right, collector) + collector = visit left, collector + collector << " NOT IN (" + visit(right, collector) << ")" + end + def visit_Arel_Nodes_And(o, collector) inject_join o.children, collector, " AND " end @@ -799,6 +769,15 @@ module Arel # :nodoc: all @connection.quote_column_name(name) end + def sanitize_as_sql_comment(value) + return value if Arel::Nodes::SqlLiteral === value + @connection.sanitize_as_sql_comment(value) + end + + def collect_optimizer_hints(o, collector) + maybe_visit o.optimizer_hints, collector + end + def maybe_visit(thing, collector) return collector unless thing collector << " " diff --git a/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb b/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb index b9880895b3..0620a515bd 100644 --- a/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb +++ b/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb @@ -9,7 +9,7 @@ module ActiveRecord class_option :timestamps, type: :boolean class_option :primary_key_type, type: :string, desc: "The type for primary key" - class_option :database, type: :string, aliases: %i(db), desc: "The database for your migration. By default, the current environment's primary database is used." + class_option :database, type: :string, aliases: %i(--db), desc: "The database for your migration. By default, the current environment's primary database is used." def create_migration_file set_local_assigns! diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt index 5f7201cfe1..562543f981 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt +++ b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt @@ -6,7 +6,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi t.string :password_digest<%= attribute.inject_options %> <% elsif attribute.token? -%> t.string :<%= attribute.name %><%= attribute.inject_options %> -<% else -%> +<% elsif !attribute.virtual? -%> t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %> <% end -%> <% end -%> diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt index 481c70201b..c07380bec9 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt +++ b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt @@ -7,7 +7,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- elsif attribute.token? -%> add_column :<%= table_name %>, :<%= attribute.name %>, :string<%= attribute.inject_options %> add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>, unique: true - <%- else -%> + <%- elsif !attribute.virtual? -%> add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> <%- if attribute.has_index? -%> add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %> @@ -21,7 +21,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- attributes.each do |attribute| -%> <%- if attribute.reference? -%> t.references :<%= attribute.name %><%= attribute.inject_options %> - <%- else -%> + <%- elsif !attribute.virtual? -%> <%= '# ' unless attribute.has_index? -%>t.index <%= attribute.index_name %><%= attribute.inject_index_options %> <%- end -%> <%- end -%> @@ -37,7 +37,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- if attribute.has_index? -%> remove_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %> <%- end -%> + <%- if !attribute.virtual? %> remove_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> + <%- end -%> <%- end -%> <%- end -%> <%- end -%> diff --git a/activerecord/lib/rails/generators/active_record/model/model_generator.rb b/activerecord/lib/rails/generators/active_record/model/model_generator.rb index eac504f9f1..c71bbdcab8 100644 --- a/activerecord/lib/rails/generators/active_record/model/model_generator.rb +++ b/activerecord/lib/rails/generators/active_record/model/model_generator.rb @@ -14,7 +14,7 @@ module ActiveRecord class_option :parent, type: :string, desc: "The parent class for the generated model" class_option :indexes, type: :boolean, default: true, desc: "Add indexes for references and belongs_to columns" class_option :primary_key_type, type: :string, desc: "The type for primary key" - class_option :database, type: :string, aliases: %i(db), desc: "The database for your model's migration. By default, the current environment's primary database is used." + class_option :database, type: :string, aliases: %i(--db), desc: "The database for your model's migration. By default, the current environment's primary database is used." # creates the migration file for the model. def create_migration_file diff --git a/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt index 55dc65c8ad..77b9ea1c86 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt +++ b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt @@ -1,7 +1,16 @@ <% module_namespacing do -%> class <%= class_name %> < <%= parent_class_name.classify %> <% attributes.select(&:reference?).each do |attribute| -%> - belongs_to :<%= attribute.name %><%= ', polymorphic: true' if attribute.polymorphic? %><%= ', required: true' if attribute.required? %> + belongs_to :<%= attribute.name %><%= ', polymorphic: true' if attribute.polymorphic? %> +<% end -%> +<% attributes.select(&:rich_text?).each do |attribute| -%> + has_rich_text :<%= attribute.name %> +<% end -%> +<% attributes.select(&:attachment?).each do |attribute| -%> + has_one_attached :<%= attribute.name %> +<% end -%> +<% attributes.select(&:attachments?).each do |attribute| -%> + has_many_attached :<%= attribute.name %> <% end -%> <% attributes.select(&:token?).each do |attribute| -%> has_secure_token<% if attribute.name != "token" %> :<%= attribute.name %><% end %> diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 05d8aa59c4..ce2ed06c1d 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -348,6 +348,10 @@ module ActiveRecord assert_equal "special_db_type", @connection.type_to_sql(:special_db_type) end + def test_supports_foreign_keys_in_create_is_deprecated + assert_deprecated { @connection.supports_foreign_keys_in_create? } + end + def test_supports_multi_insert_is_deprecated assert_deprecated { @connection.supports_multi_insert? } end @@ -448,19 +452,21 @@ module ActiveRecord class AdapterTestWithoutTransaction < ActiveRecord::TestCase self.use_transactional_tests = false - class Klass < ActiveRecord::Base - end + fixtures :posts, :authors, :author_addresses def setup - Klass.establish_connection :arunit - @connection = Klass.connection - end - - teardown do - Klass.remove_connection + @connection = ActiveRecord::Base.connection end unless in_memory_db? + test "reconnect after a disconnect" do + assert_predicate @connection, :active? + @connection.disconnect! + assert_not_predicate @connection, :active? + @connection.reconnect! + assert_predicate @connection, :active? + end + test "transaction state is reset after a reconnect" do @connection.begin_transaction assert_predicate @connection, :transaction_open? @@ -473,9 +479,65 @@ module ActiveRecord assert_predicate @connection, :transaction_open? @connection.disconnect! assert_not_predicate @connection, :transaction_open? + ensure + @connection.reconnect! end end + def test_truncate + assert_operator Post.count, :>, 0 + + @connection.truncate("posts") + + assert_equal 0, Post.count + ensure + reset_fixtures("posts") + end + + def test_truncate_with_query_cache + @connection.enable_query_cache! + + assert_operator Post.count, :>, 0 + + @connection.truncate("posts") + + assert_equal 0, Post.count + ensure + reset_fixtures("posts") + @connection.disable_query_cache! + end + + def test_truncate_tables + assert_operator Post.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 + + @connection.truncate_tables("author_addresses", "authors", "posts") + + assert_equal 0, Post.count + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + ensure + reset_fixtures("posts", "authors", "author_addresses") + end + + def test_truncate_tables_with_query_cache + @connection.enable_query_cache! + + assert_operator Post.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 + + @connection.truncate_tables("author_addresses", "authors", "posts") + + assert_equal 0, Post.count + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + ensure + reset_fixtures("posts", "authors", "author_addresses") + @connection.disable_query_cache! + end + # test resetting sequences in odd tables in PostgreSQL if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!) require "models/movie" @@ -495,6 +557,16 @@ module ActiveRecord assert_nothing_raised { sub.save! } end end + + private + + def reset_fixtures(*fixture_names) + ActiveRecord::FixtureSet.reset_cache + + fixture_names.each do |fixture_name| + ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, fixture_name) + end + end end end diff --git a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb index 88c2ac5d0a..c2c357d0c1 100644 --- a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb @@ -10,7 +10,15 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase ActiveRecord::Base.connection.send(:default_row_format) ActiveRecord::Base.connection.singleton_class.class_eval do alias_method :execute_without_stub, :execute - def execute(sql, name = nil) sql end + def execute(sql, name = nil) + ActiveSupport::Notifications.instrumenter.instrument( + "sql.active_record", + sql: sql, + name: name, + connection: self) do + sql + end + end end end @@ -89,17 +97,19 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase %w(SPATIAL FULLTEXT UNIQUE).each do |type| expected = "ALTER TABLE `people` ADD #{type} INDEX `index_people_on_last_name` (`last_name`)" - actual = ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t| - t.index :last_name, type: type + assert_sql(expected) do + ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t| + t.index :last_name, type: type + end end - assert_equal expected, actual end expected = "ALTER TABLE `people` ADD INDEX `index_people_on_last_name` USING btree (`last_name`(10)), ALGORITHM = COPY" - actual = ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t| - t.index :last_name, length: 10, using: :btree, algorithm: :copy + assert_sql(expected) do + ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t| + t.index :last_name, length: 10, using: :btree, algorithm: :copy + end end - assert_equal expected, actual end def test_drop_table diff --git a/activerecord/test/cases/adapters/mysql2/annotate_test.rb b/activerecord/test/cases/adapters/mysql2/annotate_test.rb new file mode 100644 index 0000000000..b512540073 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/annotate_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +class Mysql2AnnotateTest < ActiveRecord::Mysql2TestCase + fixtures :posts + + def test_annotate_wraps_content_in_an_inline_comment + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/}) do + posts = Post.select(:id).annotate("foo") + assert posts.first + end + end + + def test_annotate_is_sanitized + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/}) do + posts = Post.select(:id).annotate("*/foo/*") + assert posts.first + end + + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/}) do + posts = Post.select(:id).annotate("**//foo//**") + assert posts.first + end + + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/ /\* bar \*/}) do + posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") + assert posts.first + end + + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* \+ MAX_EXECUTION_TIME\(1\) \*/}) do + posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") + assert posts.first + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/connection_test.rb b/activerecord/test/cases/adapters/mysql2/connection_test.rb index 3103589186..9c6566106a 100644 --- a/activerecord/test/cases/adapters/mysql2/connection_test.rb +++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb @@ -28,17 +28,6 @@ class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase end end - def test_truncate - rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments") - count = rows.first.values.first - assert_operator count, :>, 0 - - ActiveRecord::Base.connection.truncate("comments") - rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments") - count = rows.first.values.first - assert_equal 0, count - end - def test_no_automatic_reconnection_after_timeout assert_predicate @connection, :active? @connection.update("set @@wait_timeout=1") diff --git a/activerecord/test/cases/adapters/mysql2/count_deleted_rows_with_lock_test.rb b/activerecord/test/cases/adapters/mysql2/count_deleted_rows_with_lock_test.rb new file mode 100644 index 0000000000..4d361e405c --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/count_deleted_rows_with_lock_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" +require "models/author" +require "models/bulb" + +module ActiveRecord + class CountDeletedRowsWithLockTest < ActiveRecord::Mysql2TestCase + test "delete and create in different threads synchronize correctly" do + Bulb.unscoped.delete_all + Bulb.create!(name: "Jimmy", color: "blue") + + delete_thread = Thread.new do + Bulb.unscoped.delete_all + end + + create_thread = Thread.new do + Author.create!(name: "Tommy") + end + + delete_thread.join + create_thread.join + + assert_equal 1, delete_thread.value + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb index 00a075e063..cbe55f1d53 100644 --- a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb +++ b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb @@ -46,10 +46,7 @@ class Mysql2DatetimePrecisionQuotingTest < ActiveRecord::Mysql2TestCase def stub_version(full_version_string) @connection.stub(:full_version, full_version_string) do - @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) yield end - ensure - @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) end end diff --git a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb index 7bc86476b6..6ade2eec24 100644 --- a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb +++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb @@ -56,7 +56,7 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase @conn.columns_for_distinct("posts.id", [order]) end - def test_errors_for_bigint_fks_on_integer_pk_table + def test_errors_for_bigint_fks_on_integer_pk_table_in_alter_table # table old_cars has primary key of integer error = assert_raises(ActiveRecord::MismatchedForeignKey) do @@ -64,9 +64,86 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase @conn.add_foreign_key :engines, :old_cars end - assert_match "Column `old_car_id` on table `engines` has a type of `bigint(20)`", error.message + assert_includes error.message, <<~MSG.squish + Column `old_car_id` on table `engines` does not match column `id` on `old_cars`, + which has type `int(11)`. To resolve this issue, change the type of the `old_car_id` + column on `engines` to be :integer. (For example `t.integer :old_car_id`). + MSG assert_not_nil error.cause - @conn.exec_query("ALTER TABLE engines DROP COLUMN old_car_id") + ensure + @conn.execute("ALTER TABLE engines DROP COLUMN old_car_id") rescue nil + end + + def test_errors_for_bigint_fks_on_integer_pk_table_in_create_table + # table old_cars has primary key of integer + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.execute(<<~SQL) + CREATE TABLE activerecord_unittest.foos ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + old_car_id bigint, + INDEX index_foos_on_old_car_id (old_car_id), + CONSTRAINT fk_rails_ff771f3c96 FOREIGN KEY (old_car_id) REFERENCES old_cars (id) + ) + SQL + end + + assert_includes error.message, <<~MSG.squish + Column `old_car_id` on table `foos` does not match column `id` on `old_cars`, + which has type `int(11)`. To resolve this issue, change the type of the `old_car_id` + column on `foos` to be :integer. (For example `t.integer :old_car_id`). + MSG + assert_not_nil error.cause + ensure + @conn.drop_table :foos, if_exists: true + end + + def test_errors_for_integer_fks_on_bigint_pk_table_in_create_table + # table old_cars has primary key of bigint + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.execute(<<~SQL) + CREATE TABLE activerecord_unittest.foos ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + car_id int, + INDEX index_foos_on_car_id (car_id), + CONSTRAINT fk_rails_ff771f3c96 FOREIGN KEY (car_id) REFERENCES cars (id) + ) + SQL + end + + assert_includes error.message, <<~MSG.squish + Column `car_id` on table `foos` does not match column `id` on `cars`, + which has type `bigint(20)`. To resolve this issue, change the type of the `car_id` + column on `foos` to be :bigint. (For example `t.bigint :car_id`). + MSG + assert_not_nil error.cause + ensure + @conn.drop_table :foos, if_exists: true + end + + def test_errors_for_bigint_fks_on_string_pk_table_in_create_table + # table old_cars has primary key of string + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.execute(<<~SQL) + CREATE TABLE activerecord_unittest.foos ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + subscriber_id bigint, + INDEX index_foos_on_subscriber_id (subscriber_id), + CONSTRAINT fk_rails_ff771f3c96 FOREIGN KEY (subscriber_id) REFERENCES subscribers (nick) + ) + SQL + end + + assert_includes error.message, <<~MSG.squish + Column `subscriber_id` on table `foos` does not match column `nick` on `subscribers`, + which has type `varchar(255)`. To resolve this issue, change the type of the `subscriber_id` + column on `foos` to be :string. (For example `t.string :subscriber_id`). + MSG + assert_not_nil error.cause + ensure + @conn.drop_table :foos, if_exists: true end def test_errors_when_an_insert_query_is_called_while_preventing_writes @@ -123,7 +200,15 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes @conn.while_preventing_writes do - assert_nil @conn.execute("SET NAMES utf8") + assert_nil @conn.execute("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci") + end + end + + def test_doesnt_error_when_a_read_query_with_leading_chars_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute("(\n( SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594' ) )").entries.count end end diff --git a/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb new file mode 100644 index 0000000000..628802b216 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +if supports_optimizer_hints? + class Mysql2OptimzerHintsTest < ActiveRecord::Mysql2TestCase + fixtures :posts + + def test_optimizer_hints + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |" + end + end + + def test_optimizer_hints_with_count_subquery + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)") + posts = posts.select(:id).where(author_id: [0, 1]).limit(5) + assert_equal 5, posts.count + end + end + + def test_optimizer_hints_is_sanitized + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |" + end + + assert_sql(%r{\ASELECT /\*\+ `posts`\.\*, \*/}) do + posts = Post.optimizer_hints("**// `posts`.*, //**") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_equal({ "id" => 1 }, posts.first.as_json) + end + end + + def test_optimizer_hints_with_unscope + assert_sql(%r{\ASELECT `posts`\.`id`}) do + posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */") + posts = posts.select(:id).where(author_id: [0, 1]) + posts.unscope(:optimizer_hints).load + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/schema_test.rb b/activerecord/test/cases/adapters/mysql2/schema_test.rb index 1283b0642c..b8f51acba0 100644 --- a/activerecord/test/cases/adapters/mysql2/schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb @@ -41,7 +41,7 @@ module ActiveRecord column_24 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_24" } column_25 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_25" } - # Mysql floats are precision 0..24, Mysql doubles are precision 25..53 + # MySQL floats are precision 0..24, MySQL doubles are precision 25..53 assert_equal 24, column_no_limit.limit assert_equal 24, column_short.limit assert_equal 53, column_long.limit diff --git a/activerecord/test/cases/adapters/mysql2/sp_test.rb b/activerecord/test/cases/adapters/mysql2/sp_test.rb index 7b6dce71e9..626ef59570 100644 --- a/activerecord/test/cases/adapters/mysql2/sp_test.rb +++ b/activerecord/test/cases/adapters/mysql2/sp_test.rb @@ -9,7 +9,7 @@ class Mysql2StoredProcedureTest < ActiveRecord::Mysql2TestCase def setup @connection = ActiveRecord::Base.connection - unless ActiveRecord::Base.connection.version >= "5.6.0" + unless ActiveRecord::Base.connection.database_version >= "5.6.0" skip("no stored procedure support") end end diff --git a/activerecord/test/cases/adapters/postgresql/annotate_test.rb b/activerecord/test/cases/adapters/postgresql/annotate_test.rb new file mode 100644 index 0000000000..42a2861511 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/annotate_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +class PostgresqlAnnotateTest < ActiveRecord::PostgreSQLTestCase + fixtures :posts + + def test_annotate_wraps_content_in_an_inline_comment + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("foo") + assert posts.first + end + end + + def test_annotate_is_sanitized + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("*/foo/*") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("**//foo//**") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/ /\* bar \*/}) do + posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* \+ MAX_EXECUTION_TIME\(1\) \*/}) do + posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") + assert posts.first + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/bytea_test.rb b/activerecord/test/cases/adapters/postgresql/bytea_test.rb index 3988c2adca..531e6b2328 100644 --- a/activerecord/test/cases/adapters/postgresql/bytea_test.rb +++ b/activerecord/test/cases/adapters/postgresql/bytea_test.rb @@ -35,7 +35,7 @@ class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase def test_binary_columns_are_limitless_the_upper_limit_is_one_GB assert_equal "bytea", @connection.type_to_sql(:binary, limit: 100_000) - assert_raise ActiveRecord::ActiveRecordError do + assert_raise ArgumentError do @connection.type_to_sql(:binary, limit: 4294967295) end end diff --git a/activerecord/test/cases/adapters/postgresql/connection_test.rb b/activerecord/test/cases/adapters/postgresql/connection_test.rb index 40ab158c05..9494863a75 100644 --- a/activerecord/test/cases/adapters/postgresql/connection_test.rb +++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb @@ -25,28 +25,20 @@ module ActiveRecord super end - def test_truncate - count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i - assert_operator count, :>, 0 - ActiveRecord::Base.connection.truncate("comments") - count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i - assert_equal 0, count - end - def test_encoding - assert_queries(1) do + assert_queries(1, ignore_none: true) do assert_not_nil @connection.encoding end end def test_collation - assert_queries(1) do + assert_queries(1, ignore_none: true) do assert_not_nil @connection.collation end end def test_ctype - assert_queries(1) do + assert_queries(1, ignore_none: true) do assert_not_nil @connection.ctype end end diff --git a/activerecord/test/cases/adapters/postgresql/datatype_test.rb b/activerecord/test/cases/adapters/postgresql/datatype_test.rb index b7535d5c9a..562cf1f2d1 100644 --- a/activerecord/test/cases/adapters/postgresql/datatype_test.rb +++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb @@ -64,7 +64,7 @@ class PostgresqlDataTypeTest < ActiveRecord::PostgreSQLTestCase def test_text_columns_are_limitless_the_upper_limit_is_one_GB assert_equal "text", @connection.type_to_sql(:text, limit: 100_000) - assert_raise ActiveRecord::ActiveRecordError do + assert_raise ArgumentError do @connection.type_to_sql(:text, limit: 4294967295) end end diff --git a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb index df97ab11e7..0fd7b2c6ed 100644 --- a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb +++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb @@ -22,23 +22,26 @@ class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase @connection = ActiveRecord::Base.connection - @old_schema_migration_table_name = ActiveRecord::SchemaMigration.table_name @old_table_name_prefix = ActiveRecord::Base.table_name_prefix @old_table_name_suffix = ActiveRecord::Base.table_name_suffix ActiveRecord::Base.table_name_prefix = "p_" ActiveRecord::Base.table_name_suffix = "_s" + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name + ActiveRecord::SchemaMigration.delete_all rescue nil - ActiveRecord::SchemaMigration.table_name = "p_schema_migrations_s" ActiveRecord::Migration.verbose = false end def teardown - ActiveRecord::Base.table_name_prefix = @old_table_name_prefix - ActiveRecord::Base.table_name_suffix = @old_table_name_suffix ActiveRecord::SchemaMigration.delete_all rescue nil ActiveRecord::Migration.verbose = true - ActiveRecord::SchemaMigration.table_name = @old_schema_migration_table_name + + ActiveRecord::Base.table_name_prefix = @old_table_name_prefix + ActiveRecord::Base.table_name_suffix = @old_table_name_suffix + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name super end diff --git a/activerecord/test/cases/adapters/postgresql/geometric_test.rb b/activerecord/test/cases/adapters/postgresql/geometric_test.rb index 8c6f046553..14c262f4ce 100644 --- a/activerecord/test/cases/adapters/postgresql/geometric_test.rb +++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb @@ -247,7 +247,7 @@ class PostgreSQLGeometricLineTest < ActiveRecord::PostgreSQLTestCase class PostgresqlLine < ActiveRecord::Base; end setup do - unless ActiveRecord::Base.connection.send(:postgresql_version) >= 90400 + unless ActiveRecord::Base.connection.database_version >= 90400 skip("line type is not fully implemented") end @connection = ActiveRecord::Base.connection diff --git a/activerecord/test/cases/adapters/postgresql/hstore_test.rb b/activerecord/test/cases/adapters/postgresql/hstore_test.rb index 4b061a9375..671d8211a7 100644 --- a/activerecord/test/cases/adapters/postgresql/hstore_test.rb +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "support/schema_dumping_helper" +require "support/stubs/strong_parameters" class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase include SchemaDumpingHelper @@ -11,12 +12,6 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase store_accessor :settings, :language, :timezone end - class FakeParameters - def to_unsafe_h - { "hi" => "hi" } - end - end - def setup @connection = ActiveRecord::Base.connection @@ -158,6 +153,22 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase assert_equal "GMT", y.timezone end + def test_changes_with_store_accessors + x = Hstore.new(language: "de") + assert x.language_changed? + assert_nil x.language_was + assert_equal [nil, "de"], x.language_change + x.save! + + assert_not x.language_changed? + x.reload + + x.settings = nil + assert x.language_changed? + assert_equal "de", x.language_was + assert_equal ["de", nil], x.language_change + end + def test_changes_in_place hstore = Hstore.create!(settings: { "one" => "two" }) hstore.settings["three"] = "four" @@ -344,7 +355,7 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase end def test_supports_to_unsafe_h_values - assert_equal("\"hi\"=>\"hi\"", @type.serialize(FakeParameters.new)) + assert_equal "\"hi\"=>\"hi\"", @type.serialize(ProtectedParams.new("hi" => "hi")) end private diff --git a/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb new file mode 100644 index 0000000000..5b9f5e0832 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +if supports_optimizer_hints? + class PostgresqlOptimzerHintsTest < ActiveRecord::PostgreSQLTestCase + fixtures :posts + + def setup + enable_extension!("pg_hint_plan", ActiveRecord::Base.connection) + end + + def test_optimizer_hints + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("SeqScan(posts)") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "Seq Scan on posts" + end + end + + def test_optimizer_hints_with_count_subquery + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("SeqScan(posts)") + posts = posts.select(:id).where(author_id: [0, 1]).limit(5) + assert_equal 5, posts.count + end + end + + def test_optimizer_hints_is_sanitized + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("/*+ SeqScan(posts) */") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "Seq Scan on posts" + end + + assert_sql(%r{\ASELECT /\*\+ "posts"\.\*, \*/}) do + posts = Post.optimizer_hints("**// \"posts\".*, //**") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_equal({ "id" => 1 }, posts.first.as_json) + end + end + + def test_optimizer_hints_with_unscope + assert_sql(%r{\ASELECT "posts"\."id"}) do + posts = Post.optimizer_hints("/*+ SeqScan(posts) */") + posts = posts.select(:id).where(author_id: [0, 1]) + posts.unscope(:optimizer_hints).load + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb index 0ac9ca1200..4015bc94f9 100644 --- a/activerecord/test/cases/adapters/postgresql/partitions_test.rb +++ b/activerecord/test/cases/adapters/postgresql/partitions_test.rb @@ -12,7 +12,7 @@ class PostgreSQLPartitionsTest < ActiveRecord::PostgreSQLTestCase end def test_partitions_table_exists - skip unless ActiveRecord::Base.connection.postgresql_version >= 100000 + skip unless ActiveRecord::Base.connection.database_version >= 100000 @connection.create_table :partitioned_events, force: true, id: false, options: "partition by range (issued_at)" do |t| t.timestamp :issued_at diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb index 34b4fc344b..fbd3cbf90f 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -432,6 +432,16 @@ module ActiveRecord end end + def test_doesnt_error_when_a_read_query_with_leading_chars_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @connection.while_preventing_writes do + assert_equal 1, @connection.execute("(\n( SELECT * FROM ex WHERE data = '138853948594' ) )").entries.count + end + end + end + private def with_example_table(definition = "id serial primary key, number integer, data character varying(255)", &block) diff --git a/activerecord/test/cases/adapters/postgresql/range_test.rb b/activerecord/test/cases/adapters/postgresql/range_test.rb index 478cd5aa76..068f1e8bea 100644 --- a/activerecord/test/cases/adapters/postgresql/range_test.rb +++ b/activerecord/test/cases/adapters/postgresql/range_test.rb @@ -375,6 +375,22 @@ class PostgresqlRangeTest < ActiveRecord::PostgreSQLTestCase assert_equal(-Float::INFINITY...Float::INFINITY, record.float_range) end + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.6.0") + def test_endless_range_values + record = PostgresqlRange.create!( + int4_range: eval("1.."), + int8_range: eval("10.."), + float_range: eval("0.5..") + ) + + record = PostgresqlRange.find(record.id) + + assert_equal 1...Float::INFINITY, record.int4_range + assert_equal 10...Float::INFINITY, record.int8_range + assert_equal 0.5...Float::INFINITY, record.float_range + end + end + private def assert_equal_round_trip(range, attribute, value) round_trip(range, attribute, value) diff --git a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb index ba477c63f4..96cfabf58f 100644 --- a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb +++ b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb @@ -13,7 +13,7 @@ class PostgreSQLReferentialIntegrityTest < ActiveRecord::PostgreSQLTestCase end module MissingSuperuserPrivileges - def execute(sql) + def execute(sql, name = nil) if IS_REFERENTIAL_INTEGRITY_SQL.call(sql) super "BROKEN;" rescue nil # put transaction in broken state raise ActiveRecord::StatementInvalid, "PG::InsufficientPrivilege" @@ -24,7 +24,7 @@ class PostgreSQLReferentialIntegrityTest < ActiveRecord::PostgreSQLTestCase end module ProgrammerMistake - def execute(sql) + def execute(sql, name = nil) if IS_REFERENTIAL_INTEGRITY_SQL.call(sql) raise ArgumentError, "something is not right." else diff --git a/activerecord/test/cases/adapters/postgresql/uuid_test.rb b/activerecord/test/cases/adapters/postgresql/uuid_test.rb index 9912763c1b..d2d8ea8042 100644 --- a/activerecord/test/cases/adapters/postgresql/uuid_test.rb +++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb @@ -114,6 +114,22 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase assert_equal "foobar", uuid.guid_before_type_cast end + def test_invalid_uuid_dont_match_to_nil + UUIDType.create! + assert_empty UUIDType.where(guid: "") + assert_empty UUIDType.where(guid: "foobar") + end + + class DuckUUID + def initialize(uuid) + @uuid = uuid + end + + def to_s + @uuid + end + end + def test_acceptable_uuid_regex # Valid uuids ["A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11", @@ -125,9 +141,11 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase # so we shouldn't block it either. (Pay attention to "fb6d" – the "f" here # is invalid – it must be one of 8, 9, A, B, a, b according to the spec.) "{a0eebc99-9c0b-4ef8-fb6d-6bb9bd380a11}", + # Support Object-Oriented UUIDs which respond to #to_s + DuckUUID.new("A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11"), ].each do |valid_uuid| uuid = UUIDType.new guid: valid_uuid - assert_not_nil uuid.guid + assert_instance_of String, uuid.guid end # Invalid uuids diff --git a/activerecord/test/cases/adapters/sqlite3/annotate_test.rb b/activerecord/test/cases/adapters/sqlite3/annotate_test.rb new file mode 100644 index 0000000000..6567a5eca3 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/annotate_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +class SQLite3AnnotateTest < ActiveRecord::SQLite3TestCase + fixtures :posts + + def test_annotate_wraps_content_in_an_inline_comment + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("foo") + assert posts.first + end + end + + def test_annotate_is_sanitized + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("*/foo/*") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("**//foo//**") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/ /\* bar \*/}) do + posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* \+ MAX_EXECUTION_TIME\(1\) \*/}) do + posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") + assert posts.first + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb deleted file mode 100644 index 93a7dafebd..0000000000 --- a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "cases/helper" -require "models/topic" - -module ActiveRecord - module ConnectionAdapters - class SQLite3Adapter - class BindParameterTest < ActiveRecord::SQLite3TestCase - def test_too_many_binds - topics = Topic.where(id: (1..999).to_a << 2**63) - assert_equal Topic.count, topics.count - - topics = Topic.where.not(id: (1..999).to_a << 2**63) - assert_equal 0, topics.count - end - end - end - end -end diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb index 5c41c14171..806cfbfc00 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -625,6 +625,16 @@ module ActiveRecord end end + def test_doesnt_error_when_a_read_query_with_leading_chars_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute(" SELECT data from ex WHERE data = '138853948594'").count + end + end + end + private def assert_logged(logs) diff --git a/activerecord/test/cases/ar_schema_test.rb b/activerecord/test/cases/ar_schema_test.rb index 9d88b14dab..7aa6d089c5 100644 --- a/activerecord/test/cases/ar_schema_test.rb +++ b/activerecord/test/cases/ar_schema_test.rb @@ -51,11 +51,11 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase assert_equal 7, @connection.migration_context.current_version end - def test_schema_define_w_table_name_prefix - table_name = ActiveRecord::SchemaMigration.table_name + def test_schema_define_with_table_name_prefix old_table_name_prefix = ActiveRecord::Base.table_name_prefix ActiveRecord::Base.table_name_prefix = "nep_" - ActiveRecord::SchemaMigration.table_name = "nep_#{table_name}" + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name ActiveRecord::Schema.define(version: 7) do create_table :fruits do |t| t.column :color, :string @@ -67,7 +67,8 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase assert_equal 7, @connection.migration_context.current_version ensure ActiveRecord::Base.table_name_prefix = old_table_name_prefix - ActiveRecord::SchemaMigration.table_name = table_name + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name end def test_schema_raises_an_error_for_invalid_column_type @@ -159,7 +160,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase end if subsecond_precision_supported? - def test_timestamps_sets_presicion_on_create_table + def test_timestamps_sets_precision_on_create_table ActiveRecord::Schema.define do create_table :has_timestamps do |t| t.timestamps @@ -170,7 +171,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) end - def test_timestamps_sets_presicion_on_change_table + def test_timestamps_sets_precision_on_change_table ActiveRecord::Schema.define do create_table :has_timestamps @@ -184,7 +185,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase end if ActiveRecord::Base.connection.supports_bulk_alter? - def test_timestamps_sets_presicion_on_change_table_with_bulk + def test_timestamps_sets_precision_on_change_table_with_bulk ActiveRecord::Schema.define do create_table :has_timestamps @@ -198,7 +199,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase end end - def test_timestamps_sets_presicion_on_add_timestamps + def test_timestamps_sets_precision_on_add_timestamps ActiveRecord::Schema.define do create_table :has_timestamps add_timestamps :has_timestamps, default: Time.now diff --git a/activerecord/test/cases/arel/insert_manager_test.rb b/activerecord/test/cases/arel/insert_manager_test.rb index 2376ad8d37..79b85742ee 100644 --- a/activerecord/test/cases/arel/insert_manager_test.rb +++ b/activerecord/test/cases/arel/insert_manager_test.rb @@ -11,19 +11,18 @@ module Arel end describe "insert" do - it "can create a Values node" do + it "can create a ValuesList node" do manager = Arel::InsertManager.new - values = manager.create_values %w{ a b }, %w{ c d } + values = manager.create_values_list([%w{ a b }, %w{ c d }]) - assert_kind_of Arel::Nodes::Values, values - assert_equal %w{ a b }, values.left - assert_equal %w{ c d }, values.right + assert_kind_of Arel::Nodes::ValuesList, values + assert_equal [%w{ a b }, %w{ c d }], values.rows end it "allows sql literals" do manager = Arel::InsertManager.new manager.into Table.new(:users) - manager.values = manager.create_values [Arel.sql("*")], %w{ a } + manager.values = manager.create_values([Arel.sql("*")]) manager.to_sql.must_be_like %{ INSERT INTO \"users\" VALUES (*) } @@ -186,9 +185,9 @@ module Arel manager = Arel::InsertManager.new manager.into table - manager.values = Nodes::Values.new [1] + manager.values = Nodes::ValuesList.new([[1], [2]]) manager.to_sql.must_be_like %{ - INSERT INTO "users" VALUES (1) + INSERT INTO "users" VALUES (1), (2) } end @@ -210,11 +209,11 @@ module Arel manager = Arel::InsertManager.new manager.into table - manager.values = Nodes::Values.new [1, "aaron"] + manager.values = Nodes::ValuesList.new([[1, "aaron"], [2, "david"]]) manager.columns << table[:id] manager.columns << table[:name] manager.to_sql.must_be_like %{ - INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron'), (2, 'david') } end end diff --git a/activerecord/test/cases/arel/nodes/comment_test.rb b/activerecord/test/cases/arel/nodes/comment_test.rb new file mode 100644 index 0000000000..bf5eaf4c5a --- /dev/null +++ b/activerecord/test/cases/arel/nodes/comment_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "yaml" + +module Arel + module Nodes + class CommentTest < Arel::Spec + describe "equality" do + it "is equal with equal contents" do + array = [Comment.new(["foo"]), Comment.new(["foo"])] + assert_equal 1, array.uniq.size + end + + it "is not equal with different contents" do + array = [Comment.new(["foo"]), Comment.new(["bar"])] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/select_core_test.rb b/activerecord/test/cases/arel/nodes/select_core_test.rb index 0b698205ff..6860f2a395 100644 --- a/activerecord/test/cases/arel/nodes/select_core_test.rb +++ b/activerecord/test/cases/arel/nodes/select_core_test.rb @@ -37,6 +37,7 @@ module Arel core1.groups = %w[j k l] core1.windows = %w[m n o] core1.havings = %w[p q r] + core1.comment = Arel::Nodes::Comment.new(["comment"]) core2 = SelectCore.new core2.froms = %w[a b c] core2.projections = %w[d e f] @@ -44,6 +45,7 @@ module Arel core2.groups = %w[j k l] core2.windows = %w[m n o] core2.havings = %w[p q r] + core2.comment = Arel::Nodes::Comment.new(["comment"]) array = [core1, core2] assert_equal 1, array.uniq.size end @@ -56,6 +58,7 @@ module Arel core1.groups = %w[j k l] core1.windows = %w[m n o] core1.havings = %w[p q r] + core1.comment = Arel::Nodes::Comment.new(["comment"]) core2 = SelectCore.new core2.froms = %w[a b c] core2.projections = %w[d e f] @@ -63,6 +66,11 @@ module Arel core2.groups = %w[j k l] core2.windows = %w[m n o] core2.havings = %w[l o l] + core2.comment = Arel::Nodes::Comment.new(["comment"]) + array = [core1, core2] + assert_equal 2, array.uniq.size + core2.havings = %w[p q r] + core2.comment = Arel::Nodes::Comment.new(["other"]) array = [core1, core2] assert_equal 2, array.uniq.size end diff --git a/activerecord/test/cases/arel/select_manager_test.rb b/activerecord/test/cases/arel/select_manager_test.rb index 5220950905..e6c49cd429 100644 --- a/activerecord/test/cases/arel/select_manager_test.rb +++ b/activerecord/test/cases/arel/select_manager_test.rb @@ -1221,5 +1221,28 @@ module Arel manager.distinct_on(false).must_equal manager end end + + describe "comment" do + it "chains" do + manager = Arel::SelectManager.new + manager.comment("selecting").must_equal manager + end + + it "appends a comment to the generated query" do + manager = Arel::SelectManager.new + table = Table.new :users + manager.from(table).project(table["id"]) + + manager.comment("selecting") + manager.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" /* selecting */ + } + + manager.comment("selecting", "with", "comment") + manager.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" /* selecting */ /* with */ /* comment */ + } + end + end end end diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb index 559ff5d4e6..5ebeabd4a3 100644 --- a/activerecord/test/cases/arel/support/fake_record.rb +++ b/activerecord/test/cases/arel/support/fake_record.rb @@ -58,6 +58,14 @@ module FakeRecord "\"#{name}\"" end + def sanitize_as_sql_comment(comment) + comment + end + + def in_clause_length + 3 + end + def schema_cache self end diff --git a/activerecord/test/cases/arel/visitors/depth_first_test.rb b/activerecord/test/cases/arel/visitors/depth_first_test.rb index f94ad521d7..106be2311d 100644 --- a/activerecord/test/cases/arel/visitors/depth_first_test.rb +++ b/activerecord/test/cases/arel/visitors/depth_first_test.rb @@ -33,6 +33,7 @@ module Arel Arel::Nodes::Ordering, Arel::Nodes::StringJoin, Arel::Nodes::UnqualifiedColumn, + Arel::Nodes::ValuesList, Arel::Nodes::Limit, Arel::Nodes::Else, ].each do |klass| @@ -100,6 +101,12 @@ module Arel assert_equal [:a, :b, join], @collector.calls end + def test_comment + comment = Nodes::Comment.new ["foo"] + @visitor.accept comment + assert_equal ["foo", ["foo"], comment], @collector.calls + end + [ Arel::Nodes::Assignment, Arel::Nodes::Between, @@ -116,7 +123,6 @@ module Arel Arel::Nodes::NotIn, Arel::Nodes::Or, Arel::Nodes::TableAlias, - Arel::Nodes::Values, Arel::Nodes::As, Arel::Nodes::DeleteStatement, Arel::Nodes::JoinSource, diff --git a/activerecord/test/cases/arel/visitors/dot_test.rb b/activerecord/test/cases/arel/visitors/dot_test.rb index 6b3c132f83..ade53c358e 100644 --- a/activerecord/test/cases/arel/visitors/dot_test.rb +++ b/activerecord/test/cases/arel/visitors/dot_test.rb @@ -37,6 +37,7 @@ module Arel Arel::Nodes::Offset, Arel::Nodes::Ordering, Arel::Nodes::UnqualifiedColumn, + Arel::Nodes::ValuesList, Arel::Nodes::Limit, ].each do |klass| define_method("test_#{klass.name.gsub('::', '_')}") do @@ -61,7 +62,6 @@ module Arel Arel::Nodes::NotIn, Arel::Nodes::Or, Arel::Nodes::TableAlias, - Arel::Nodes::Values, Arel::Nodes::As, Arel::Nodes::DeleteStatement, Arel::Nodes::JoinSource, diff --git a/activerecord/test/cases/arel/visitors/to_sql_test.rb b/activerecord/test/cases/arel/visitors/to_sql_test.rb index 4bfa799a96..fd19574876 100644 --- a/activerecord/test/cases/arel/visitors/to_sql_test.rb +++ b/activerecord/test/cases/arel/visitors/to_sql_test.rb @@ -23,9 +23,9 @@ module Arel sql.must_be_like "?" end - it "does not quote BindParams used as part of a Values" do + it "does not quote BindParams used as part of a ValuesList" do bp = Nodes::BindParam.new(1) - values = Nodes::Values.new([bp]) + values = Nodes::ValuesList.new([[bp]]) sql = compile values sql.must_be_like "VALUES (?)" end @@ -395,6 +395,11 @@ module Arel compile(node).must_be_like %{ "users"."id" IN (1, 2, 3) } + + node = @attr.in [1, 2, 3, 4, 5] + compile(node).must_be_like %{ + ("users"."id" IN (1, 2, 3) OR "users"."id" IN (4, 5)) + } end it "should return 1=0 when empty right which is always false" do @@ -545,6 +550,11 @@ module Arel compile(node).must_be_like %{ "users"."id" NOT IN (1, 2, 3) } + + node = @attr.not_in [1, 2, 3, 4, 5] + compile(node).must_be_like %{ + "users"."id" NOT IN (1, 2, 3) AND "users"."id" NOT IN (4, 5) + } end it "should return 1=1 when empty right which is always true" do diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index c01138c059..3525fa2ab8 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -448,8 +448,13 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end def test_with_select - assert_equal 1, Company.find(2).firm_with_select.attributes.size - assert_equal 1, Company.all.merge!(includes: :firm_with_select).find(2).firm_with_select.attributes.size + assert_equal 1, Post.find(2).author_with_select.attributes.size + assert_equal 1, Post.includes(:author_with_select).find(2).author_with_select.attributes.size + end + + def test_custom_attribute_with_select + assert_equal 2, Company.find(2).firm_with_select.attributes.size + assert_equal 2, Company.includes(:firm_with_select).find(2).firm_with_select.attributes.size end def test_belongs_to_without_counter_cache_option diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index a9e22c7643..49f754be63 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -18,7 +18,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase :categorizations, :people, :categories, :edges, :vertices def test_eager_association_loading_with_cascaded_two_levels - authors = Author.all.merge!(includes: { posts: :comments }, order: "authors.id").to_a + authors = Author.includes(posts: :comments).order(:id).to_a assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 3, authors[1].posts.size @@ -26,7 +26,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_cascaded_two_levels_and_one_level - authors = Author.all.merge!(includes: [{ posts: :comments }, :categorizations], order: "authors.id").to_a + authors = Author.includes({ posts: :comments }, :categorizations).order(:id).to_a assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 3, authors[1].posts.size @@ -36,7 +36,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_hmt_does_not_table_name_collide_when_joining_associations - authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a + authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).order(:id).to_a assert_equal 3, assert_no_queries { authors.size } assert_equal 10, assert_no_queries { authors[0].comments.size } end @@ -121,7 +121,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase assert reply.save topics = Topic.all.merge!(includes: :replies, order: ["topics.id", "replies_topics.id"]).to_a - assert_no_queries do + assert_queries(0) do assert_equal 2, topics[0].replies.size assert_equal 0, topics[1].replies.size end diff --git a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb index 5fca972aee..673d5f1dcf 100644 --- a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb +++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb @@ -21,7 +21,7 @@ module PolymorphicFullStiClassNamesSharedTest ActiveRecord::Base.store_full_sti_class = store_full_sti_class post = Namespaced::Post.create(title: "Great stuff", body: "This is not", author_id: 1) - @tagging = Tagging.create(taggable: post) + @tagging = post.create_tagging! end def teardown diff --git a/activerecord/test/cases/associations/eager_load_nested_include_test.rb b/activerecord/test/cases/associations/eager_load_nested_include_test.rb index 525ad3197a..849939de75 100644 --- a/activerecord/test/cases/associations/eager_load_nested_include_test.rb +++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb @@ -110,10 +110,10 @@ class EagerLoadNestedIncludeWithMissingDataTest < ActiveRecord::TestCase end teardown do - @davey_mcdave.destroy - @first_post.destroy @first_comment.destroy @first_categorization.destroy + @davey_mcdave.destroy + @first_post.destroy end def test_missing_data_in_a_nested_include_should_not_cause_errors_when_constructing_objects diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index b37e59038e..49bcb1d010 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -4,6 +4,7 @@ require "cases/helper" require "models/post" require "models/tagging" require "models/tag" +require "models/rating" require "models/comment" require "models/author" require "models/essay" @@ -44,7 +45,7 @@ end class EagerAssociationTest < ActiveRecord::TestCase fixtures :posts, :comments, :authors, :essays, :author_addresses, :categories, :categories_posts, - :companies, :accounts, :tags, :taggings, :people, :readers, :categorizations, + :companies, :accounts, :tags, :taggings, :ratings, :people, :readers, :categorizations, :owners, :pets, :author_favorites, :jobs, :references, :subscribers, :subscriptions, :books, :developers, :projects, :developers_projects, :members, :memberships, :clubs, :sponsors @@ -89,6 +90,17 @@ class EagerAssociationTest < ActiveRecord::TestCase "expected to find only david's posts" end + def test_loading_polymorphic_association_with_mixed_table_conditions + rating = Rating.first + assert_equal [taggings(:normal_comment_rating)], rating.taggings_without_tag + + rating = Rating.preload(:taggings_without_tag).first + assert_equal [taggings(:normal_comment_rating)], rating.taggings_without_tag + + rating = Rating.eager_load(:taggings_without_tag).first + assert_equal [taggings(:normal_comment_rating)], rating.taggings_without_tag + end + def test_loading_with_scope_including_joins member = Member.first assert_equal members(:groucho), member @@ -228,7 +240,7 @@ class EagerAssociationTest < ActiveRecord::TestCase end def test_load_associated_records_in_one_query_when_adapter_has_no_limit - assert_called(Comment.connection, :in_clause_length, returns: nil) do + assert_not_called(Comment.connection, :in_clause_length) do post = posts(:welcome) assert_queries(2) do Post.includes(:comments).where(id: post.id).to_a @@ -237,16 +249,16 @@ class EagerAssociationTest < ActiveRecord::TestCase end def test_load_associated_records_in_several_queries_when_many_ids_passed - assert_called(Comment.connection, :in_clause_length, returns: 1) do + assert_called(Comment.connection, :in_clause_length, times: 2, returns: 1) do post1, post2 = posts(:welcome), posts(:thinking) - assert_queries(3) do + assert_queries(2) do Post.includes(:comments).where(id: [post1.id, post2.id]).to_a end end end def test_load_associated_records_in_one_query_when_a_few_ids_passed - assert_called(Comment.connection, :in_clause_length, returns: 3) do + assert_not_called(Comment.connection, :in_clause_length) do post = posts(:welcome) assert_queries(2) do Post.includes(:comments).where(id: post.id).to_a @@ -1259,12 +1271,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_include_has_many_using_primary_key expected = Firm.find(1).clients_using_primary_key.sort_by(&:name) - # Oracle adapter truncates alias to 30 characters - if current_adapter?(:OracleAdapter) - firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies"[0, 30] + ".name").find(1) - else - firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies.name").find(1) - end + firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies.name").find(1) assert_no_queries do assert_equal expected, firm.clients_using_primary_key end @@ -1393,11 +1400,24 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal expected, FirstPost.unscoped.find(2) end - test "preload ignores the scoping" do - assert_equal( - Comment.find(1).post, - Post.where("1 = 0").scoping { Comment.preload(:post).find(1).post } - ) + test "belongs_to association ignores the scoping" do + post = Comment.find(1).post + + Post.where("1=0").scoping do + assert_equal post, Comment.find(1).post + assert_equal post, Comment.preload(:post).find(1).post + assert_equal post, Comment.eager_load(:post).find(1).post + end + end + + test "has_many association ignores the scoping" do + comments = Post.find(1).comments.to_a + + Comment.where("1=0").scoping do + assert_equal comments, Post.find(1).comments + assert_equal comments, Post.preload(:comments).find(1).comments + assert_equal comments, Post.eager_load(:comments).find(1).comments + end end test "deep preload" do @@ -1496,6 +1516,24 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_match message, error.message end + test "preloading and eager loading of optional instance dependent associations is not supported" do + message = "association scope 'posts_mentioning_author' is" + error = assert_raises(ArgumentError) do + Author.includes(:posts_mentioning_author).to_a + end + assert_match message, error.message + + error = assert_raises(ArgumentError) do + Author.preload(:posts_mentioning_author).to_a + end + assert_match message, error.message + + error = assert_raises(ArgumentError) do + Author.eager_load(:posts_mentioning_author).to_a + end + assert_match message, error.message + end + test "preload with invalid argument" do exception = assert_raises(ArgumentError) do Author.preload(10).to_a diff --git a/activerecord/test/cases/associations/extension_test.rb b/activerecord/test/cases/associations/extension_test.rb index aef8f31112..5e6e3e8ec4 100644 --- a/activerecord/test/cases/associations/extension_test.rb +++ b/activerecord/test/cases/associations/extension_test.rb @@ -70,8 +70,8 @@ class AssociationsExtensionsTest < ActiveRecord::TestCase extend!(Developer) extend!(MyApplication::Business::Developer) - assert Object.const_get "DeveloperAssociationNameAssociationExtension" - assert MyApplication::Business.const_get "DeveloperAssociationNameAssociationExtension" + assert Developer.const_get "AssociationNameAssociationExtension" + assert MyApplication::Business::Developer.const_get "AssociationNameAssociationExtension" end def test_proxy_association_after_scoped @@ -89,6 +89,6 @@ class AssociationsExtensionsTest < ActiveRecord::TestCase private def extend!(model) - ActiveRecord::Associations::Builder::HasMany.define_extensions(model, :association_name) { } + ActiveRecord::Associations::Builder::HasMany.send(:define_extensions, model, :association_name) { } end end diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index bf44be1811..32285f269a 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -1230,10 +1230,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter? # Count should come from sql count() of treasures rather than treasures_count attribute - assert_queries(1) do - assert_equal ship.treasures.size, 0 - assert_predicate ship.treasures, :loaded? - end + assert_equal ship.treasures.size, 0 assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do ship.treasures.create(name: "Gold") @@ -1354,20 +1351,6 @@ class HasManyAssociationsTest < ActiveRecord::TestCase post = posts(:welcome) assert_no_queries do assert_not_empty post.comments - assert_equal 2, post.comments.size - assert_not_predicate post.comments, :loaded? - end - post = posts(:misc_by_bob) - assert_no_queries do - assert_empty post.comments - assert_predicate post.comments, :loaded? - end - end - - def test_empty_association_loading_with_counter_cache - post = posts(:misc_by_bob) - assert_no_queries do - assert_empty post.comments.to_a end end @@ -1755,6 +1738,14 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_nil posts.first end + def test_destroy_all_on_desynced_counter_cache_association + category = categories(:general) + assert_operator category.categorizations.count, :>, 0 + + category.categorizations.destroy_all + assert_equal 0, category.categorizations.count + end + def test_destroy_on_association_clears_scope author = Author.create!(name: "Gannon") posts = author.posts @@ -2004,6 +1995,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_not_predicate company.clients, :loaded? end + def test_counter_cache_on_unloaded_association + car = Car.create(name: "My AppliCar") + assert_equal 0, car.engines.size + end + def test_ids_reader_cache_not_used_for_size_when_association_is_dirty firm = Firm.create!(name: "Startup") assert_equal 0, firm.client_ids.size @@ -2019,24 +2015,6 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal [3, 11], firm.client_ids end - def test_zero_counter_cache_usage_on_unloaded_association - car = Car.create!(name: "My AppliCar") - assert_no_queries do - assert_equal car.engines.size, 0 - assert_predicate car.engines, :loaded? - end - end - - def test_counter_cache_on_new_record_unloaded_association - car = Car.new(name: "My AppliCar") - # Ensure no schema queries inside assertion - Engine.primary_key - assert_no_queries do - assert_equal car.engines.size, 0 - assert_predicate car.engines, :loaded? - end - end - def test_get_ids_ignores_include_option assert_equal [readers(:michael_welcome).id], posts(:welcome).readers_with_person_ids end @@ -2095,10 +2073,12 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_associations_order_should_be_priority_over_throughs_order - david = authors(:david) + original = authors(:david) expected = [12, 10, 9, 8, 7, 6, 5, 3, 2, 1] - assert_equal expected, david.comments_desc.map(&:id) - assert_equal expected, Author.includes(:comments_desc).find(david.id).comments_desc.map(&:id) + assert_equal expected, original.comments_desc.map(&:id) + preloaded = Author.includes(:comments_desc).find(original.id) + assert_equal expected, preloaded.comments_desc.map(&:id) + assert_equal original.posts_sorted_by_id.first.comments.map(&:id), preloaded.posts_sorted_by_id.first.comments.map(&:id) end def test_dynamic_find_should_respect_association_order_for_through @@ -2598,22 +2578,22 @@ class HasManyAssociationsTest < ActiveRecord::TestCase test "association with extend option" do post = posts(:welcome) - assert_equal "lifo", post.comments_with_extend.author - assert_equal "hello", post.comments_with_extend.greeting + assert_equal "lifo", post.comments_with_extend.author + assert_equal "hello :)", post.comments_with_extend.greeting end test "association with extend option with multiple extensions" do post = posts(:welcome) - assert_equal "lifo", post.comments_with_extend_2.author - assert_equal "hullo", post.comments_with_extend_2.greeting + assert_equal "lifo", post.comments_with_extend_2.author + assert_equal "hullo :)", post.comments_with_extend_2.greeting end test "extend option affects per association" do post = posts(:welcome) - assert_equal "lifo", post.comments_with_extend.author - assert_equal "lifo", post.comments_with_extend_2.author - assert_equal "hello", post.comments_with_extend.greeting - assert_equal "hullo", post.comments_with_extend_2.greeting + assert_equal "lifo", post.comments_with_extend.author + assert_equal "lifo", post.comments_with_extend_2.author + assert_equal "hello :)", post.comments_with_extend.greeting + assert_equal "hullo :)", post.comments_with_extend_2.greeting end test "delete record with complex joins" do diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 0ac56c6168..0ab99aa6cd 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -33,6 +33,9 @@ require "models/organization" require "models/user" require "models/family" require "models/family_tree" +require "models/section" +require "models/seminar" +require "models/session" class HasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags, @@ -55,6 +58,15 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal preloaded, Marshal.load(Marshal.dump(preloaded)) end + def test_preload_with_nested_association + posts = Post.preload(:author, :author_favorites_with_scope).to_a + + assert_no_queries do + posts.each(&:author) + posts.each(&:author_favorites_with_scope) + end + end + def test_preload_sti_rhs_class developers = Developer.includes(:firms).all.to_a assert_no_queries do @@ -737,10 +749,14 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase firm = companies(:first_firm) lifo = Developer.new(name: "lifo") - assert_raises(ActiveRecord::RecordInvalid) { firm.developers << lifo } + assert_raises(ActiveRecord::RecordInvalid) do + assert_deprecated { firm.developers << lifo } + end lifo = Developer.create!(name: "lifo") - assert_raises(ActiveRecord::RecordInvalid) { firm.developers << lifo } + assert_raises(ActiveRecord::RecordInvalid) do + assert_deprecated { firm.developers << lifo } + end end end @@ -1151,7 +1167,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase def test_create_should_not_raise_exception_when_join_record_has_errors repair_validations(Categorization) do Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" } - Category.create(name: "Fishing", authors: [Author.first]) + assert_deprecated { Category.create(name: "Fishing", authors: [Author.first]) } end end @@ -1164,7 +1180,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase repair_validations(Categorization) do Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" } assert_raises(ActiveRecord::RecordInvalid) do - Category.create!(name: "Fishing", authors: [Author.first]) + assert_deprecated { Category.create!(name: "Fishing", authors: [Author.first]) } end end end @@ -1174,7 +1190,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" } c = Category.new(name: "Fishing", authors: [Author.first]) assert_raises(ActiveRecord::RecordInvalid) do - c.save! + assert_deprecated { c.save! } end end end @@ -1183,7 +1199,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase repair_validations(Categorization) do Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" } c = Category.new(name: "Fishing", authors: [Author.first]) - assert_not c.save + assert_deprecated { assert_not c.save } end end @@ -1465,6 +1481,37 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [subscription2], post.subscriptions.to_a end + def test_child_is_visible_to_join_model_in_add_association_callbacks + [:before_add, :after_add].each do |callback_name| + sentient_treasure = Class.new(Treasure) do + def self.name; "SentientTreasure"; end + + has_many :pet_treasures, foreign_key: :treasure_id, callback_name => :check_pet! + has_many :pets, through: :pet_treasures + + def check_pet!(added) + raise "No pet!" if added.pet.nil? + end + end + + treasure = sentient_treasure.new + assert_nothing_raised { treasure.pets << pets(:mochi) } + end + end + + def test_circular_autosave_association_correctly_saves_multiple_records + cs180 = Seminar.new(name: "CS180") + fall = Session.new(name: "Fall") + sections = [ + cs180.sections.build(short_name: "A"), + cs180.sections.build(short_name: "B"), + ] + fall.sections << sections + fall.save! + fall.reload + assert_equal sections, fall.sections.sort_by(&:id) + end + private def make_model(name) Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb index 7bb629466d..fd727757a3 100644 --- a/activerecord/test/cases/associations/has_one_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -15,10 +15,13 @@ require "models/post" require "models/drink_designer" require "models/chef" require "models/department" +require "models/club" +require "models/membership" class HasOneAssociationsTest < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? - fixtures :accounts, :companies, :developers, :projects, :developers_projects, :ships, :pirates, :authors, :author_addresses + fixtures :accounts, :companies, :developers, :projects, :developers_projects, + :ships, :pirates, :authors, :author_addresses, :memberships, :clubs def setup Account.destroyed_account_ids.clear @@ -706,6 +709,40 @@ class HasOneAssociationsTest < ActiveRecord::TestCase end end + def test_has_one_with_touch_option_on_create + assert_queries(3) { + Club.create(name: "1000 Oaks", membership_attributes: { favourite: true }) + } + end + + def test_has_one_with_touch_option_on_update + new_club = Club.create(name: "1000 Oaks") + new_club.create_membership + + assert_queries(2) { new_club.update(name: "Effingut") } + end + + def test_has_one_with_touch_option_on_touch + new_club = Club.create(name: "1000 Oaks") + new_club.create_membership + + assert_queries(1) { new_club.touch } + end + + def test_has_one_with_touch_option_on_destroy + new_club = Club.create(name: "1000 Oaks") + new_club.create_membership + + assert_queries(2) { new_club.destroy } + end + + def test_has_one_with_touch_option_on_empty_update + new_club = Club.create(name: "1000 Oaks") + new_club.create_membership + + assert_no_queries { new_club.save } + end + class SpecialBook < ActiveRecord::Base self.table_name = "books" belongs_to :author, class_name: "SpecialAuthor" diff --git a/activerecord/test/cases/associations/inner_join_association_test.rb b/activerecord/test/cases/associations/inner_join_association_test.rb index c33dcdee61..e0dac01f4a 100644 --- a/activerecord/test/cases/associations/inner_join_association_test.rb +++ b/activerecord/test/cases/associations/inner_join_association_test.rb @@ -29,7 +29,10 @@ class InnerJoinAssociationTest < ActiveRecord::TestCase def test_construct_finder_sql_does_not_table_name_collide_on_duplicate_associations_with_left_outer_joins sql = Person.joins(agents: :agents).left_outer_joins(agents: :agents).to_sql - assert_match(/agents_people_4/i, sql) + assert_match(/agents_people_2/i, sql) + assert_match(/INNER JOIN/i, sql) + assert_no_match(/agents_people_4/i, sql) + assert_no_match(/LEFT OUTER JOIN/i, sql) end def test_construct_finder_sql_does_not_table_name_collide_with_string_joins diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb index da3a42e2b5..669e176dcb 100644 --- a/activerecord/test/cases/associations/inverse_associations_test.rb +++ b/activerecord/test/cases/associations/inverse_associations_test.rb @@ -8,6 +8,7 @@ require "models/zine" require "models/club" require "models/sponsor" require "models/rating" +require "models/post" require "models/comment" require "models/car" require "models/bulb" @@ -62,6 +63,14 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase assert_equal rating_reflection, comment_reflection.inverse_of, "The Comment reflection's inverse should be the Rating reflection" end + def test_has_many_and_belongs_to_should_find_inverse_automatically_for_extension_block + comment_reflection = Comment.reflect_on_association(:post) + post_reflection = Post.reflect_on_association(:comments) + + assert_predicate post_reflection, :has_inverse? + assert_equal comment_reflection, post_reflection.inverse_of + end + def test_has_many_and_belongs_to_should_find_inverse_automatically_for_sti author_reflection = Author.reflect_on_association(:posts) author_child_reflection = Author.reflect_on_association(:special_posts) diff --git a/activerecord/test/cases/associations/left_outer_join_association_test.rb b/activerecord/test/cases/associations/left_outer_join_association_test.rb index 0e54e8c1b0..d44c6407f5 100644 --- a/activerecord/test/cases/associations/left_outer_join_association_test.rb +++ b/activerecord/test/cases/associations/left_outer_join_association_test.rb @@ -32,6 +32,10 @@ class LeftOuterJoinAssociationTest < ActiveRecord::TestCase assert_equal 17, Post.left_outer_joins(:comments).count end + def test_merging_left_joins_should_be_left_joins + assert_equal 5, Author.left_joins(:posts).merge(Post.no_comments).count + end + def test_left_joins_aliases_left_outer_joins assert_equal Post.left_outer_joins(:comments).to_sql, Post.left_joins(:comments).to_sql end @@ -46,6 +50,12 @@ class LeftOuterJoinAssociationTest < ActiveRecord::TestCase assert queries.any? { |sql| /LEFT OUTER JOIN/i.match?(sql) } end + def test_left_outer_joins_is_deduped_when_same_association_is_joined + queries = capture_sql { Author.joins(:posts).left_outer_joins(:posts).to_a } + assert queries.any? { |sql| /INNER JOIN/i.match?(sql) } + assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) } + end + def test_construct_finder_sql_ignores_empty_left_outer_joins_hash queries = capture_sql { Author.left_outer_joins({}).to_a } assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) } @@ -60,6 +70,10 @@ class LeftOuterJoinAssociationTest < ActiveRecord::TestCase assert_raise(ArgumentError) { Author.left_outer_joins('LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"').to_a } end + def test_left_outer_joins_with_string_join + assert_equal 16, Author.left_outer_joins(:posts).joins("LEFT OUTER JOIN comments ON comments.post_id = posts.id").count + end + def test_join_conditions_added_to_join_clause queries = capture_sql { Author.left_outer_joins(:essays).to_a } assert queries.any? { |sql| /writer_type.*?=.*?(Author|\?|\$1|\:a1)/i.match?(sql) } diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index 5821744530..35da74102d 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -548,6 +548,15 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase end end + def test_through_association_preload_doesnt_reset_source_association_if_already_preloaded + blue = tags(:blue) + authors = Author.preload(posts: :first_blue_tags_2, misc_post_first_blue_tags_2: {}).to_a.sort_by(&:id) + + assert_no_queries do + assert_equal [blue], authors[2].posts.first.first_blue_tags_2 + end + end + def test_nested_has_many_through_with_conditions_on_source_associations_preload_via_joins # Pointless condition to force single-query loading assert_includes_and_joins_equal( @@ -610,6 +619,12 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase assert_equal hotel, Hotel.joins(:cake_designers, :drink_designers).take end + def test_has_many_through_reset_source_reflection_after_loading_is_complete + preloaded = Category.preload(:ordered_post_comments).find(1, 2).last + original = Category.find(2) + assert_equal original.ordered_post_comments.ids, preloaded.ordered_post_comments.ids + end + private def assert_includes_and_joins_equal(query, expected, association) diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index 081da95df7..84130ec208 100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -21,6 +21,11 @@ require "models/molecule" require "models/electron" require "models/man" require "models/interest" +require "models/pirate" +require "models/parrot" +require "models/bird" +require "models/treasure" +require "models/price_estimate" class AssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :developers_projects, @@ -368,3 +373,97 @@ class GeneratedMethodsTest < ActiveRecord::TestCase assert_equal :none, MyArticle.new.comments end end + +class WithAnnotationsTest < ActiveRecord::TestCase + fixtures :pirates, :parrots + + def test_belongs_to_with_annotation_includes_a_query_comment + pirate = SpacePirate.where.not(parrot_id: nil).first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.parrot + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that tells jokes \*/}) do + pirate.parrot_with_annotation + end + end + + def test_has_and_belongs_to_many_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.parrots.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that are very colorful \*/}) do + pirate.parrots_with_annotation.first + end + end + + def test_has_one_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.ship + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that is a rocket \*/}) do + pirate.ship_with_annotation + end + end + + def test_has_many_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.birds.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that are also parrots \*/}) do + pirate.birds_with_annotation.first + end + end + + def test_has_many_through_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.treasure_estimates.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* yarrr \*/}) do + pirate.treasure_estimates_with_annotation.first + end + end + + def test_has_many_through_with_annotation_includes_a_query_comment_when_eager_loading + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.treasure_estimates.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* yarrr \*/}) do + SpacePirate.includes(:treasure_estimates_with_annotation, :treasures).first + end + end +end diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index d341dd0083..5cbe5d796d 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -433,6 +433,10 @@ class AttributeMethodsTest < ActiveRecord::TestCase end assert_equal true, Topic.new(author_name: "Name").author_name? + + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + assert_predicate Topic.new(author_name: value), :author_name? + end end test "number attribute predicate" do @@ -454,6 +458,69 @@ class AttributeMethodsTest < ActiveRecord::TestCase end end + test "user-defined text attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_text, :text + end + + topic = klass.new(user_defined_text: "text") + assert_predicate topic, :user_defined_text? + + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + topic = klass.new(user_defined_text: value) + assert_predicate topic, :user_defined_text? + end + end + + test "user-defined date attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_date, :date + end + + topic = klass.new(user_defined_date: Date.current) + assert_predicate topic, :user_defined_date? + end + + test "user-defined datetime attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_datetime, :datetime + end + + topic = klass.new(user_defined_datetime: Time.current) + assert_predicate topic, :user_defined_datetime? + end + + test "user-defined time attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_time, :time + end + + topic = klass.new(user_defined_time: Time.current) + assert_predicate topic, :user_defined_time? + end + + test "user-defined json attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_json, :json + end + + topic = klass.new(user_defined_json: { key: "value" }) + assert_predicate topic, :user_defined_json? + + topic = klass.new(user_defined_json: {}) + assert_not_predicate topic, :user_defined_json? + end + test "custom field attribute predicate" do object = Company.find_by_sql(<<~SQL).first SELECT c1.*, c2.type as string_value, c2.rating as int_value @@ -711,6 +778,10 @@ class AttributeMethodsTest < ActiveRecord::TestCase record.written_on = "Jan 01 00:00:00 2014" assert_equal record, YAML.load(YAML.dump(record)) end + ensure + # NOTE: Reset column info because global topics + # don't have tz-aware attributes by default. + Topic.reset_column_information end test "setting a time zone-aware time in the current time zone" do @@ -1010,9 +1081,9 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal ["title"], model.accessed_fields end - test "generated attribute methods ancestors have correct class" do + test "generated attribute methods ancestors have correct module" do mod = Topic.send(:generated_attribute_methods) - assert_match %r(GeneratedAttributeMethods), mod.inspect + assert_equal "Topic::GeneratedAttributeMethods", mod.inspect end private diff --git a/activerecord/test/cases/attributes_test.rb b/activerecord/test/cases/attributes_test.rb index 2632aec7ab..a6fb9f0af7 100644 --- a/activerecord/test/cases/attributes_test.rb +++ b/activerecord/test/cases/attributes_test.rb @@ -56,6 +56,17 @@ module ActiveRecord assert_equal 255, UnoverloadedType.type_for_attribute("overloaded_string_with_limit").limit end + test "extra options are forwarded to the type caster constructor" do + klass = Class.new(OverloadedType) do + attribute :starts_at, :datetime, precision: 3, limit: 2, scale: 1 + end + + starts_at_type = klass.type_for_attribute(:starts_at) + assert_equal 3, starts_at_type.precision + assert_equal 2, starts_at_type.limit + assert_equal 1, starts_at_type.scale + end + test "nonexistent attribute" do data = OverloadedType.new(non_existent_decimal: 1) diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index 88df0eed55..1a0732c14b 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "models/author" require "models/bird" require "models/post" require "models/comment" @@ -1319,21 +1320,45 @@ end class TestAutosaveAssociationOnAHasOneThroughAssociation < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? - def setup - super + def create_member_with_organization organization = Organization.create - @member = Member.create - MemberDetail.create(organization: organization, member: @member) + member = Member.create + MemberDetail.create(organization: organization, member: member) + + member end def test_should_not_has_one_through_model - class << @member.organization + member = create_member_with_organization + + class << member.organization + def save(*args) + super + raise "Oh noes!" + end + end + assert_nothing_raised { member.save } + end + + def create_author_with_post_with_comment + Author.create! name: "David" # make comment_id not match author_id + author = Author.create! name: "Sergiy" + post = Post.create! author: author, title: "foo", body: "bar" + Comment.create! post: post, body: "cool comment" + + author + end + + def test_should_not_reversed_has_one_through_model + author = create_author_with_post_with_comment + + class << author.comment_on_first_post def save(*args) super raise "Oh noes!" end end - assert_nothing_raised { @member.save } + assert_nothing_raised { author.save } end end @@ -1806,7 +1831,7 @@ class TestAutosaveAssociationOnAHasManyAssociationWithInverse < ActiveRecord::Te end class TestAutosaveAssociationOnAHasManyAssociationDefinedInSubclassWithAcceptsNestedAttributes < ActiveRecord::TestCase - def test_should_update_children_when_asssociation_redefined_in_subclass + def test_should_update_children_when_association_redefined_in_subclass agency = Agency.create!(name: "Agency") valid_project = Project.create!(firm: agency, name: "Initial") agency.update!( diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index 363593ca19..ddafa468ed 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -67,6 +67,16 @@ end class BasicsTest < ActiveRecord::TestCase fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, "warehouse-things", :authors, :author_addresses, :categorizations, :categories, :posts + def test_generated_association_methods_module_name + mod = Post.send(:generated_association_methods) + assert_equal "Post::GeneratedAssociationMethods", mod.inspect + end + + def test_generated_relation_methods_module_name + mod = Post.send(:generated_relation_methods) + assert_equal "Post::GeneratedRelationMethods", mod.inspect + end + def test_column_names_are_escaped conn = ActiveRecord::Base.connection classname = conn.class.name[/[^:]*$/] @@ -438,12 +448,6 @@ class BasicsTest < ActiveRecord::TestCase Post.reset_table_name end - if current_adapter?(:Mysql2Adapter) - def test_update_all_with_order_and_limit - assert_equal 1, Topic.limit(1).order("id DESC").update_all(content: "bulk updated!") - end - end - def test_null_fields assert_nil Topic.find(1).parent_id assert_nil Topic.create("title" => "Hey you").parent_id @@ -691,6 +695,9 @@ class BasicsTest < ActiveRecord::TestCase topic = Topic.find(1) topic.attributes = attributes assert_equal Time.local(2000, 1, 1, 5, 42, 0), topic.bonus_time + + topic.save! + assert_equal topic, Topic.find_by(attributes) end end @@ -1038,11 +1045,6 @@ class BasicsTest < ActiveRecord::TestCase end end - def test_find_last - last = Developer.last - assert_equal last, Developer.all.merge!(order: "id desc").first - end - def test_last assert_equal Developer.all.merge!(order: "id desc").first, Developer.last end @@ -1058,23 +1060,23 @@ class BasicsTest < ActiveRecord::TestCase end def test_find_ordered_last - last = Developer.all.merge!(order: "developers.salary ASC").last - assert_equal last, Developer.all.merge!(order: "developers.salary ASC").to_a.last + last = Developer.order("developers.salary ASC").last + assert_equal last, Developer.order("developers.salary": "ASC").to_a.last end def test_find_reverse_ordered_last - last = Developer.all.merge!(order: "developers.salary DESC").last - assert_equal last, Developer.all.merge!(order: "developers.salary DESC").to_a.last + last = Developer.order("developers.salary DESC").last + assert_equal last, Developer.order("developers.salary": "DESC").to_a.last end def test_find_multiple_ordered_last - last = Developer.all.merge!(order: "developers.name, developers.salary DESC").last - assert_equal last, Developer.all.merge!(order: "developers.name, developers.salary DESC").to_a.last + last = Developer.order("developers.name, developers.salary DESC").last + assert_equal last, Developer.order(:"developers.name", "developers.salary": "DESC").to_a.last end def test_find_keeps_multiple_order_values - combined = Developer.all.merge!(order: "developers.name, developers.salary").to_a - assert_equal combined, Developer.all.merge!(order: ["developers.name", "developers.salary"]).to_a + combined = Developer.order("developers.name, developers.salary").to_a + assert_equal combined, Developer.order(:"developers.name", :"developers.salary").to_a end def test_find_keeps_multiple_group_values @@ -1213,6 +1215,8 @@ class BasicsTest < ActiveRecord::TestCase wr.close assert Marshal.load rd.read rd.close + ensure + self.class.send(:remove_const, "Post") if self.class.const_defined?("Post", false) end end @@ -1226,14 +1230,15 @@ class BasicsTest < ActiveRecord::TestCase end def test_attribute_names - assert_equal ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description"], - Company.attribute_names + expected = ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description", "metadata"] + assert_equal expected, Company.attribute_names end def test_has_attribute assert Company.has_attribute?("id") assert Company.has_attribute?("type") assert Company.has_attribute?("name") + assert Company.has_attribute?("metadata") assert_not Company.has_attribute?("lastname") assert_not Company.has_attribute?("age") end @@ -1535,7 +1540,7 @@ class BasicsTest < ActiveRecord::TestCase Bird.create!(name: "Bluejay") ActiveRecord::Base.connection.while_preventing_writes do - assert_nothing_raised { Bird.where(name: "Bluejay").explain } + assert_queries(2) { Bird.where(name: "Bluejay").explain } end end diff --git a/activerecord/test/cases/bind_parameter_test.rb b/activerecord/test/cases/bind_parameter_test.rb index 22a98036f3..85685d1d00 100644 --- a/activerecord/test/cases/bind_parameter_test.rb +++ b/activerecord/test/cases/bind_parameter_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "models/topic" +require "models/reply" require "models/author" require "models/post" @@ -34,6 +35,77 @@ if ActiveRecord::Base.connection.prepared_statements ActiveSupport::Notifications.unsubscribe(@subscription) end + def test_statement_cache + @connection.clear_cache! + + topics = Topic.where(id: 1) + assert_equal [1], topics.map(&:id) + assert_includes statement_cache, to_sql_key(topics.arel) + + @connection.clear_cache! + + assert_not_includes statement_cache, to_sql_key(topics.arel) + end + + def test_statement_cache_with_query_cache + @connection.enable_query_cache! + @connection.clear_cache! + + topics = Topic.where(id: 1) + assert_equal [1], topics.map(&:id) + assert_includes statement_cache, to_sql_key(topics.arel) + ensure + @connection.disable_query_cache! + end + + def test_statement_cache_with_find + @connection.clear_cache! + + assert_equal 1, Topic.find(1).id + assert_raises(RecordNotFound) { SillyReply.find(2) } + + topic_sql = cached_statement(Topic, Topic.primary_key) + assert_includes statement_cache, to_sql_key(topic_sql) + + e = assert_raise { cached_statement(SillyReply, SillyReply.primary_key) } + assert_equal "SillyReply has no cached statement by \"id\"", e.message + + replies = SillyReply.where(id: 2).limit(1) + assert_includes statement_cache, to_sql_key(replies.arel) + end + + def test_statement_cache_with_find_by + @connection.clear_cache! + + assert_equal 1, Topic.find_by!(id: 1).id + assert_raises(RecordNotFound) { SillyReply.find_by!(id: 2) } + + topic_sql = cached_statement(Topic, [:id]) + assert_includes statement_cache, to_sql_key(topic_sql) + + e = assert_raise { cached_statement(SillyReply, [:id]) } + assert_equal "SillyReply has no cached statement by [:id]", e.message + + replies = SillyReply.where(id: 2).limit(1) + assert_includes statement_cache, to_sql_key(replies.arel) + end + + def test_statement_cache_with_in_clause + @connection.clear_cache! + + topics = Topic.where(id: [1, 3]) + assert_equal [1, 3], topics.map(&:id) + assert_not_includes statement_cache, to_sql_key(topics.arel) + end + + def test_statement_cache_with_sql_string_literal + @connection.clear_cache! + + topics = Topic.where("topics.id = ?", 1) + assert_equal [1], topics.map(&:id) + assert_not_includes statement_cache, to_sql_key(topics.arel) + end + def test_too_many_binds bind_params_length = @connection.send(:bind_params_length) @@ -44,6 +116,19 @@ if ActiveRecord::Base.connection.prepared_statements assert_equal 0, topics.count end + def test_too_many_binds_with_query_cache + @connection.enable_query_cache! + + bind_params_length = @connection.send(:bind_params_length) + topics = Topic.where(id: (1 .. bind_params_length + 1).to_a) + assert_equal Topic.count, topics.count + + topics = Topic.where.not(id: (1 .. bind_params_length + 1).to_a) + assert_equal 0, topics.count + ensure + @connection.disable_query_cache! + end + def test_bind_from_join_in_subquery subquery = Author.joins(:thinking_posts).where(name: "David") scope = Author.from(subquery, "authors").where(id: 1) @@ -78,12 +163,28 @@ if ActiveRecord::Base.connection.prepared_statements end private + def to_sql_key(arel) + sql = @connection.to_sql(arel) + @connection.respond_to?(:sql_key, true) ? @connection.send(:sql_key, sql) : sql + end + + def cached_statement(klass, key) + cache = klass.send(:cached_find_by_statement, key) do + raise "#{klass} has no cached statement by #{key.inspect}" + end + cache.send(:query_builder).instance_variable_get(:@sql) + end + + def statement_cache + @connection.instance_variable_get(:@statements).send(:cache) + end + def assert_logs_binds(binds) payload = { name: "SQL", sql: "select * from topics where id = ?", binds: binds, - type_casted_binds: @connection.type_casted_binds(binds) + type_casted_binds: @connection.send(:type_casted_binds, binds) } event = ActiveSupport::Notifications::Event.new( diff --git a/activerecord/test/cases/boolean_test.rb b/activerecord/test/cases/boolean_test.rb index ab9f974e2c..18824004d2 100644 --- a/activerecord/test/cases/boolean_test.rb +++ b/activerecord/test/cases/boolean_test.rb @@ -40,4 +40,13 @@ class BooleanTest < ActiveRecord::TestCase assert_equal b_false, Boolean.find_by(value: "false") assert_equal b_true, Boolean.find_by(value: "true") end + + def test_find_by_falsy_boolean_symbol + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + b_false = Boolean.create!(value: value) + + assert_not_predicate b_false, :value? + assert_equal b_false, Boolean.find_by(id: b_false.id, value: value.to_s.to_sym) + end + end end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index 850bc49676..16c2a3661d 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -19,6 +19,7 @@ require "models/developer" require "models/post" require "models/comment" require "models/rating" +require "support/stubs/strong_parameters" class CalculationsTest < ActiveRecord::TestCase fixtures :companies, :accounts, :topics, :speedometers, :minivans, :books, :posts, :comments @@ -242,6 +243,12 @@ class CalculationsTest < ActiveRecord::TestCase assert_queries(1) { assert_equal 11, posts.count(:all) } end + def test_count_with_eager_loading_and_custom_select_and_order + posts = Post.includes(:comments).order("comments.id").select(:type) + assert_queries(1) { assert_equal 11, posts.count } + assert_queries(1) { assert_equal 11, posts.count(:all) } + end + def test_count_with_eager_loading_and_custom_order_and_distinct posts = Post.includes(:comments).order("comments.id").distinct assert_queries(1) { assert_equal 11, posts.count } @@ -278,6 +285,18 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).offset(2).count end + def test_distinct_joins_count_with_group_by + expected = { nil => 4, 1 => 1, 2 => 1, 4 => 1, 5 => 1, 7 => 1 } + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.count(:author_id) + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.select(:author_id).count + assert_equal expected, Post.left_joins(:comments).group(:post_id).count("DISTINCT posts.author_id") + assert_equal expected, Post.left_joins(:comments).group(:post_id).select("DISTINCT posts.author_id").count + + expected = { nil => 6, 1 => 1, 2 => 1, 4 => 1, 5 => 1, 7 => 1 } + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.count(:all) + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.select(:author_id).count(:all) + end + def test_distinct_count_with_group_by_and_order_and_limit assert_equal({ 6 => 2 }, Account.group(:firm_id).distinct.order("1 DESC").limit(1).count) end @@ -344,6 +363,17 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal 60, c[2] end + def test_should_calculate_grouped_with_longer_field + field = "a" * Account.connection.max_identifier_length + + Account.update_all("#{field} = credit_limit") + + c = Account.group(:firm_id).sum(field) + assert_equal 50, c[1] + assert_equal 105, c[6] + assert_equal 60, c[2] + end + def test_should_calculate_with_invalid_field assert_equal 6, Account.calculate(:count, "*") assert_equal 6, Account.calculate(:count, :all) @@ -460,6 +490,24 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal 6, Account.select("DISTINCT accounts.id").includes(:firm).count end + def test_should_count_manual_select_with_count_all + assert_equal 5, Account.select("DISTINCT accounts.firm_id").count(:all) + end + + def test_should_count_with_manual_distinct_select_and_distinct + assert_equal 4, Account.select("DISTINCT accounts.firm_id").distinct(true).count + end + + def test_should_count_manual_select_with_group_with_count_all + expected = { nil => 1, 1 => 1, 2 => 1, 6 => 2, 9 => 1 } + actual = Account.select("DISTINCT accounts.firm_id").group("accounts.firm_id").count(:all) + assert_equal expected, actual + end + + def test_should_count_manual_with_count_all + assert_equal 6, Account.count(:all) + end + def test_count_selected_arel_attribute assert_equal 5, Account.select(Account.arel_table[:firm_id]).count assert_equal 4, Account.distinct.select(Account.arel_table[:firm_id]).count @@ -511,8 +559,10 @@ class CalculationsTest < ActiveRecord::TestCase end def test_should_count_field_of_root_table_with_conflicting_group_by_column - assert_equal({ 1 => 1 }, Firm.joins(:accounts).group(:firm_id).count) - assert_equal({ 1 => 1 }, Firm.joins(:accounts).group("accounts.firm_id").count) + expected = { 1 => 2, 2 => 1, 4 => 5, 5 => 2, 7 => 1 } + assert_equal expected, Post.joins(:comments).group(:post_id).count + assert_equal expected, Post.joins(:comments).group("comments.post_id").count + assert_equal expected, Post.joins(:comments).group(:post_id).select("DISTINCT posts.author_id").count(:all) end def test_count_with_no_parameters_isnt_deprecated @@ -690,8 +740,9 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_not_auto_table_name_prefix_if_column_joined - Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) - assert_equal [7], Company.joins(:contracts).pluck(:developer_id) + company = Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) + metadata = company.contracts.first.metadata + assert_equal [metadata], Company.joins(:contracts).pluck(:metadata) end def test_pluck_with_selection_clause @@ -882,26 +933,7 @@ class CalculationsTest < ActiveRecord::TestCase end def test_having_with_strong_parameters - protected_params = Class.new do - attr_reader :permitted - alias :permitted? :permitted - - def initialize(parameters) - @parameters = parameters - @permitted = false - end - - def to_h - @parameters - end - - def permit! - @permitted = true - self - end - end - - params = protected_params.new(credit_limit: "50") + params = ProtectedParams.new(credit_limit: "50") assert_raises(ActiveModel::ForbiddenAttributesError) do Account.group(:id).having(params) diff --git a/activerecord/test/cases/callbacks_test.rb b/activerecord/test/cases/callbacks_test.rb index 4d6a112af5..b4026078f1 100644 --- a/activerecord/test/cases/callbacks_test.rb +++ b/activerecord/test/cases/callbacks_test.rb @@ -458,10 +458,6 @@ class CallbacksTest < ActiveRecord::TestCase [ :before_validation, :object ], [ :before_validation, :block ], [ :before_validation, :throwing_abort ], - [ :after_rollback, :block ], - [ :after_rollback, :object ], - [ :after_rollback, :proc ], - [ :after_rollback, :method ], ], david.history end diff --git a/activerecord/test/cases/collection_cache_key_test.rb b/activerecord/test/cases/collection_cache_key_test.rb index 483383257b..f07f3c42e6 100644 --- a/activerecord/test/cases/collection_cache_key_test.rb +++ b/activerecord/test/cases/collection_cache_key_test.rb @@ -171,5 +171,39 @@ module ActiveRecord assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) end + + test "cache_key should be stable when using collection_cache_versioning" do + with_collection_cache_versioning do + developers = Developer.where(salary: 100000) + + assert_match(/\Adevelopers\/query-(\h+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + end + end + + test "cache_version for relation" do + with_collection_cache_versioning do + developers = Developer.where(salary: 100000).order(updated_at: :desc) + last_developer_timestamp = developers.first.updated_at + + assert_match(/(\d+)-(\d+)\z/, developers.cache_version) + + /(\d+)-(\d+)\z/ =~ developers.cache_version + + assert_equal developers.count.to_s, $1 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $2 + end + end + + def with_collection_cache_versioning(value = true) + @old_collection_cache_versioning = ActiveRecord::Base.collection_cache_versioning + ActiveRecord::Base.collection_cache_versioning = value + yield + ensure + ActiveRecord::Base.collection_cache_versioning = @old_collection_cache_versioning + end end end diff --git a/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb index 36591097b6..d3184f39f5 100644 --- a/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb @@ -203,26 +203,53 @@ module ActiveRecord assert_equal "must provide a `database` or a `role`.", error.message end - def test_switching_connections_with_database_symbol + def test_switching_connections_with_database_symbol_uses_default_role previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" config = { "default_env" => { - "readonly" => { adapter: "sqlite3", database: "db/readonly.sqlite3" }, - "primary" => { adapter: "sqlite3", database: "db/primary.sqlite3" } + "animals" => { adapter: "sqlite3", database: "db/animals.sqlite3" }, + "primary" => { adapter: "sqlite3", database: "db/primary.sqlite3" } } } @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config - ActiveRecord::Base.connected_to(database: :readonly) do - assert_equal :readonly, ActiveRecord::Base.current_role - assert ActiveRecord::Base.connected_to?(role: :readonly) + ActiveRecord::Base.connected_to(database: :animals) do + assert_equal :writing, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :writing) handler = ActiveRecord::Base.connection_handler - assert_equal handler, ActiveRecord::Base.connection_handlers[:readonly] + assert_equal handler, ActiveRecord::Base.connection_handlers[:writing] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal(config["default_env"]["animals"], pool.spec.config) + end + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_with_database_hash_uses_passed_role_and_database + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "animals" => { adapter: "sqlite3", database: "db/animals.sqlite3" }, + "primary" => { adapter: "sqlite3", database: "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connected_to(database: { writing: :primary }) do + assert_equal :writing, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :writing) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:writing] assert_not_nil pool = handler.retrieve_connection_pool("primary") - assert_equal(config["default_env"]["readonly"], pool.spec.config) + assert_equal(config["default_env"]["primary"], pool.spec.config) end ensure ActiveRecord::Base.configurations = @prev_configs diff --git a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb index 225cccc62c..515bf5df06 100644 --- a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -72,6 +72,16 @@ module ActiveRecord assert_equal expected, actual end + def test_resolver_with_database_uri_and_multiple_envs + ENV["DATABASE_URL"] = "postgres://localhost" + ENV["RAILS_ENV"] = "test" + + config = { "production" => { "adapter" => "postgresql", "database" => "foo_prod" }, "test" => { "adapter" => "postgresql", "database" => "foo_test" } } + actual = resolve_spec(:test, config) + expected = { "adapter" => "postgresql", "database" => "foo_test", "host" => "localhost", "name" => "test" } + assert_equal expected, actual + end + def test_resolver_with_database_uri_and_unknown_symbol_key ENV["DATABASE_URL"] = "postgres://localhost/foo" config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index 727cab77f5..28e232b88f 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -6,8 +6,9 @@ module ActiveRecord module ConnectionAdapters class SchemaCacheTest < ActiveRecord::TestCase def setup - connection = ActiveRecord::Base.connection - @cache = SchemaCache.new connection + @connection = ActiveRecord::Base.connection + @cache = SchemaCache.new @connection + @database_version = @connection.get_database_version end def test_primary_key @@ -19,6 +20,7 @@ module ActiveRecord @cache.columns_hash("posts") @cache.data_sources("posts") @cache.primary_keys("posts") + @cache.indexes("posts") new_cache = YAML.load(YAML.dump(@cache)) assert_no_queries do @@ -26,19 +28,47 @@ module ActiveRecord assert_equal 12, new_cache.columns_hash("posts").size assert new_cache.data_sources("posts") assert_equal "id", new_cache.primary_keys("posts") + assert_equal 1, new_cache.indexes("posts").size + assert_equal @database_version.to_s, new_cache.database_version.to_s end end def test_yaml_loads_5_1_dump - body = File.open(schema_dump_path).read - cache = YAML.load(body) + @cache = YAML.load(File.read(schema_dump_path)) assert_no_queries do - assert_equal 11, cache.columns("posts").size - assert_equal 11, cache.columns_hash("posts").size - assert cache.data_sources("posts") - assert_equal "id", cache.primary_keys("posts") + assert_equal 11, @cache.columns("posts").size + assert_equal 11, @cache.columns_hash("posts").size + assert @cache.data_sources("posts") + assert_equal "id", @cache.primary_keys("posts") + end + end + + def test_yaml_loads_5_1_dump_without_indexes_still_queries_for_indexes + @cache = YAML.load(File.read(schema_dump_path)) + + # Simulate assignment in railtie after loading the cache. + old_cache, @connection.schema_cache = @connection.schema_cache, @cache + + assert_queries :any, ignore_none: true do + assert_equal 1, @cache.indexes("posts").size end + ensure + @connection.schema_cache = old_cache + end + + def test_yaml_loads_5_1_dump_without_database_version_still_queries_for_database_version + @cache = YAML.load(File.read(schema_dump_path)) + + # Simulate assignment in railtie after loading the cache. + old_cache, @connection.schema_cache = @connection.schema_cache, @cache + + # We can't verify queries get executed because the database version gets + # cached in both MySQL and PostgreSQL outside of the schema cache. + assert_nil @cache.instance_variable_get(:@database_version) + assert_equal @database_version.to_s, @cache.database_version.to_s + ensure + @connection.schema_cache = old_cache end def test_primary_key_for_non_existent_table @@ -55,15 +85,34 @@ module ActiveRecord assert_equal columns_hash, @cache.columns_hash("posts") end + def test_caches_indexes + indexes = @cache.indexes("posts") + assert_equal indexes, @cache.indexes("posts") + end + + def test_caches_database_version + @cache.database_version # cache database_version + + assert_no_queries do + assert_equal @database_version.to_s, @cache.database_version.to_s + + if current_adapter?(:Mysql2Adapter) + assert_not_nil @cache.database_version.full_version_string + end + end + end + def test_clearing @cache.columns("posts") @cache.columns_hash("posts") @cache.data_sources("posts") @cache.primary_keys("posts") + @cache.indexes("posts") @cache.clear! assert_equal 0, @cache.size + assert_nil @cache.instance_variable_get(:@database_version) end def test_dump_and_load @@ -71,6 +120,7 @@ module ActiveRecord @cache.columns_hash("posts") @cache.data_sources("posts") @cache.primary_keys("posts") + @cache.indexes("posts") @cache = Marshal.load(Marshal.dump(@cache)) @@ -79,6 +129,8 @@ module ActiveRecord assert_equal 12, @cache.columns_hash("posts").size assert @cache.data_sources("posts") assert_equal "id", @cache.primary_keys("posts") + assert_equal 1, @cache.indexes("posts").size + assert_equal @database_version.to_s, @cache.database_version.to_s end end @@ -108,7 +160,6 @@ module ActiveRecord end private - def schema_dump_path "test/assets/schema_dump_5_1.yml" end diff --git a/activerecord/test/cases/database_configurations_test.rb b/activerecord/test/cases/database_configurations_test.rb new file mode 100644 index 0000000000..ed8151f01a --- /dev/null +++ b/activerecord/test/cases/database_configurations_test.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "cases/helper" + +class DatabaseConfigurationsTest < ActiveRecord::TestCase + unless in_memory_db? + def test_empty_returns_true_when_db_configs_are_empty + old_config = ActiveRecord::Base.configurations + config = {} + + ActiveRecord::Base.configurations = config + + assert_predicate ActiveRecord::Base.configurations, :empty? + assert_predicate ActiveRecord::Base.configurations, :blank? + ensure + ActiveRecord::Base.configurations = old_config + ActiveRecord::Base.establish_connection :arunit + end + end + + def test_configs_for_getter_with_env_name + configs = ActiveRecord::Base.configurations.configs_for(env_name: "arunit") + + assert_equal 1, configs.size + assert_equal ["arunit"], configs.map(&:env_name) + end + + def test_configs_for_getter_with_env_and_spec_name + config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", spec_name: "primary") + + assert_equal "arunit", config.env_name + assert_equal "primary", config.spec_name + end + + def test_default_hash_returns_config_hash_from_default_env + original_rails_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "arunit" + + assert_equal ActiveRecord::Base.configurations.configs_for(env_name: "arunit", spec_name: "primary").config, ActiveRecord::Base.configurations.default_hash + ensure + ENV["RAILS_ENV"] = original_rails_env + end + + def test_find_db_config_returns_a_db_config_object_for_the_given_env + config = ActiveRecord::Base.configurations.find_db_config("arunit2") + + assert_equal "arunit2", config.env_name + assert_equal "primary", config.spec_name + end + + def test_to_h_turns_db_config_object_back_into_a_hash + configs = ActiveRecord::Base.configurations + assert_equal "ActiveRecord::DatabaseConfigurations", configs.class.name + assert_equal "Hash", configs.to_h.class.name + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"], ActiveRecord::Base.configurations.to_h.keys.sort + end +end + +class LegacyDatabaseConfigurationsTest < ActiveRecord::TestCase + unless in_memory_db? + def test_setting_configurations_hash + old_config = ActiveRecord::Base.configurations + config = { "adapter" => "sqlite3" } + + assert_deprecated do + ActiveRecord::Base.configurations["readonly"] = config + end + + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements", "readonly"], ActiveRecord::Base.configurations.configs_for.map(&:env_name).sort + ensure + ActiveRecord::Base.configurations = old_config + ActiveRecord::Base.establish_connection :arunit + end + end + + def test_can_turn_configurations_into_a_hash + assert ActiveRecord::Base.configurations.to_h.is_a?(Hash), "expected to be a hash but was not." + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"].sort, ActiveRecord::Base.configurations.to_h.keys.sort + end + + def test_each_is_deprecated + assert_deprecated do + ActiveRecord::Base.configurations.each do |db_config| + assert_equal "primary", db_config.spec_name + end + end + end + + def test_first_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_fetch_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.fetch("arunit").first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_values_are_deprecated + config_hashes = ActiveRecord::Base.configurations.configurations.map(&:config) + assert_deprecated do + assert_equal config_hashes, ActiveRecord::Base.configurations.values + end + end + + def test_unsupported_method_raises + assert_raises NotImplementedError do + ActiveRecord::Base.configurations.select { |a| a == "foo" } + end + end +end diff --git a/activerecord/test/cases/database_selector_test.rb b/activerecord/test/cases/database_selector_test.rb index 4106a6ec46..fd02d2acb4 100644 --- a/activerecord/test/cases/database_selector_test.rb +++ b/activerecord/test/cases/database_selector_test.rb @@ -11,6 +11,10 @@ module ActiveRecord @session = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session.new(@session_store) end + teardown do + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + def test_empty_session assert_equal Time.at(0), @session.last_write_timestamp end diff --git a/activerecord/test/cases/date_test.rb b/activerecord/test/cases/date_test.rb index 9f412cdb63..2475d4a34b 100644 --- a/activerecord/test/cases/date_test.rb +++ b/activerecord/test/cases/date_test.rb @@ -23,23 +23,13 @@ class DateTest < ActiveRecord::TestCase valid_dates.each do |date_src| topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s) - # Oracle DATE columns are datetime columns and Oracle adapter returns Time value - if current_adapter?(:OracleAdapter) - assert_equal(topic.last_read.to_date, Date.new(*date_src)) - else - assert_equal(topic.last_read, Date.new(*date_src)) - end + assert_equal(topic.last_read, Date.new(*date_src)) end invalid_dates.each do |date_src| assert_nothing_raised do topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s) - # Oracle DATE columns are datetime columns and Oracle adapter returns Time value - if current_adapter?(:OracleAdapter) - assert_equal(topic.last_read.to_date, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") - else - assert_equal(topic.last_read, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") - end + assert_equal(topic.last_read, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") end end end diff --git a/activerecord/test/cases/date_time_precision_test.rb b/activerecord/test/cases/date_time_precision_test.rb index e64a8372d0..79d63949ca 100644 --- a/activerecord/test/cases/date_time_precision_test.rb +++ b/activerecord/test/cases/date_time_precision_test.rb @@ -29,7 +29,7 @@ if subsecond_precision_supported? def test_datetime_precision_is_truncated_on_assignment @connection.create_table(:foos, force: true) - @connection.add_column :foos, :created_at, :datetime, precision: 0 + @connection.add_column :foos, :created_at, :datetime, precision: 0 @connection.add_column :foos, :updated_at, :datetime, precision: 6 time = ::Time.now.change(nsec: 123456789) @@ -45,6 +45,26 @@ if subsecond_precision_supported? assert_equal 123456000, foo.updated_at.nsec end + unless current_adapter?(:Mysql2Adapter) + def test_no_datetime_precision_isnt_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :created_at, :datetime + @connection.add_column :foos, :updated_at, :datetime, precision: 6 + + time = ::Time.now.change(nsec: 123) + foo = Foo.new(created_at: time, updated_at: time) + + assert_equal 123, foo.created_at.nsec + assert_equal 0, foo.updated_at.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.created_at.nsec + assert_equal 0, foo.updated_at.nsec + end + end + def test_timestamps_helper_with_custom_precision @connection.create_table(:foos, force: true) do |t| t.timestamps precision: 4 @@ -62,7 +82,7 @@ if subsecond_precision_supported? end def test_invalid_datetime_precision_raises_error - assert_raises ActiveRecord::ActiveRecordError do + assert_raises ArgumentError do @connection.create_table(:foos, force: true) do |t| t.timestamps precision: 7 end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb index 5d02e59ef6..50a86b0a19 100644 --- a/activerecord/test/cases/defaults_test.rb +++ b/activerecord/test/cases/defaults_test.rb @@ -89,7 +89,7 @@ if current_adapter?(:PostgreSQLAdapter) test "schema dump includes default expression" do output = dump_table_schema("defaults") - if ActiveRecord::Base.connection.postgresql_version >= 100000 + if ActiveRecord::Base.connection.database_version >= 100000 assert_match %r/t\.date\s+"modified_date",\s+default: -> { "CURRENT_DATE" }/, output assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "CURRENT_TIMESTAMP" }/, output else diff --git a/activerecord/test/cases/dirty_test.rb b/activerecord/test/cases/dirty_test.rb index dfd74bfcb4..a2a501a794 100644 --- a/activerecord/test/cases/dirty_test.rb +++ b/activerecord/test/cases/dirty_test.rb @@ -352,7 +352,7 @@ class DirtyTest < ActiveRecord::TestCase Person.where(id: person.id).update_all(first_name: "baz") end - old_lock_version = person.lock_version + old_lock_version = person.lock_version + 1 with_partial_writes Person, true do assert_no_queries { 2.times { person.save! } } diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb index 8a0f6f6df1..ae0ce195b3 100644 --- a/activerecord/test/cases/enum_test.rb +++ b/activerecord/test/cases/enum_test.rb @@ -44,6 +44,11 @@ class EnumTest < ActiveRecord::TestCase assert_equal books(:rfr), authors(:david).unpublished_books.first end + test "find via negative scope" do + assert Book.not_published.exclude?(@book) + assert Book.not_proposed.include?(@book) + end + test "find via where with values" do published, written = Book.statuses[:published], Book.statuses[:written] diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 4040682280..ca114d468e 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -3,6 +3,7 @@ require "cases/helper" require "models/post" require "models/author" +require "models/account" require "models/categorization" require "models/comment" require "models/company" @@ -226,14 +227,14 @@ class FinderTest < ActiveRecord::TestCase end def test_exists_with_strong_parameters - assert_equal false, Subscriber.exists?(Parameters.new(nick: "foo").permit!) + assert_equal false, Subscriber.exists?(ProtectedParams.new(nick: "foo").permit!) Subscriber.create!(nick: "foo") - assert_equal true, Subscriber.exists?(Parameters.new(nick: "foo").permit!) + assert_equal true, Subscriber.exists?(ProtectedParams.new(nick: "foo").permit!) assert_raises(ActiveModel::ForbiddenAttributesError) do - Subscriber.exists?(Parameters.new(nick: "foo")) + Subscriber.exists?(ProtectedParams.new(nick: "foo")) end end @@ -461,14 +462,14 @@ class FinderTest < ActiveRecord::TestCase end def test_find_by_association_subquery - author = authors(:david) - assert_equal author.post, Post.find_by(author: Author.where(id: author)) - assert_equal author.post, Post.find_by(author_id: Author.where(id: author)) + firm = companies(:first_firm) + assert_equal firm.account, Account.find_by(firm: Firm.where(id: firm)) + assert_equal firm.account, Account.find_by(firm_id: Firm.where(id: firm)) end def test_find_by_and_where_consistency_with_active_record_instance - author = authors(:david) - assert_equal Post.where(author_id: author).take, Post.find_by(author_id: author) + firm = companies(:first_firm) + assert_equal Account.where(firm_id: firm).take, Account.find_by(firm_id: firm) end def test_take diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb index b4f28fbfd6..0cb868da6e 100644 --- a/activerecord/test/cases/fixtures_test.rb +++ b/activerecord/test/cases/fixtures_test.rb @@ -209,7 +209,7 @@ class FixturesTest < ActiveRecord::TestCase conn = ActiveRecord::Base.connection mysql_margin = 2 packet_size = 1024 - bytes_needed_to_have_a_1024_bytes_fixture = 858 + bytes_needed_to_have_a_1024_bytes_fixture = 906 fixtures = { "traffic_lights" => [ { "location" => "US", "state" => ["NY"], "long_state" => ["a" * bytes_needed_to_have_a_1024_bytes_fixture] }, @@ -496,11 +496,7 @@ class FixturesTest < ActiveRecord::TestCase ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "parrots") end - if current_adapter?(:SQLite3Adapter) - assert_equal(%(table "parrots" has no column named "arrr".), e.message) - else - assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message) - end + assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message) end def test_yaml_file_with_symbol_columns diff --git a/activerecord/test/cases/forbidden_attributes_protection_test.rb b/activerecord/test/cases/forbidden_attributes_protection_test.rb index 101fa118c8..e7e31b6d2d 100644 --- a/activerecord/test/cases/forbidden_attributes_protection_test.rb +++ b/activerecord/test/cases/forbidden_attributes_protection_test.rb @@ -1,48 +1,12 @@ # frozen_string_literal: true require "cases/helper" -require "active_support/core_ext/hash/indifferent_access" - require "models/company" require "models/person" require "models/ship" require "models/ship_part" require "models/treasure" - -class ProtectedParams - attr_accessor :permitted - alias :permitted? :permitted - - delegate :keys, :key?, :has_key?, :empty?, to: :@parameters - - def initialize(attributes) - @parameters = attributes.with_indifferent_access - @permitted = false - end - - def permit! - @permitted = true - self - end - - def [](key) - @parameters[key] - end - - def to_h - @parameters - end - - def stringify_keys - dup - end - - def dup - super.tap do |duplicate| - duplicate.instance_variable_set :@permitted, @permitted - end - end -end +require "support/stubs/strong_parameters" class ForbiddenAttributesProtectionTest < ActiveRecord::TestCase def test_forbidden_attributes_cannot_be_used_for_mass_assignment diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb index 730cd663a2..543a0aeb39 100644 --- a/activerecord/test/cases/helper.rb +++ b/activerecord/test/cases/helper.rb @@ -53,12 +53,22 @@ def supports_default_expression? true elsif current_adapter?(:Mysql2Adapter) conn = ActiveRecord::Base.connection - !conn.mariadb? && conn.version >= "8.0.13" + !conn.mariadb? && conn.database_version >= "8.0.13" end end -def supports_savepoints? - ActiveRecord::Base.connection.supports_savepoints? +%w[ + supports_savepoints? + supports_partial_index? + supports_insert_returning? + supports_insert_on_duplicate_skip? + supports_insert_on_duplicate_update? + supports_insert_conflict_target? + supports_optimizer_hints? +].each do |method_name| + define_method method_name do + ActiveRecord::Base.connection.public_send(method_name) + end end def with_env_tz(new_tz = "US/Eastern") @@ -192,3 +202,5 @@ module InTimeZone ActiveRecord::Base.time_zone_aware_attributes = old_tz end end + +require_relative "../../../tools/test_common" diff --git a/activerecord/test/cases/inheritance_test.rb b/activerecord/test/cases/inheritance_test.rb index 19655a2d38..629167e9ed 100644 --- a/activerecord/test/cases/inheritance_test.rb +++ b/activerecord/test/cases/inheritance_test.rb @@ -514,10 +514,12 @@ class InheritanceComputeTypeTest < ActiveRecord::TestCase # Should fail without FirmOnTheFly in the type condition. assert_raise(ActiveRecord::RecordNotFound) { Firm.find(foo.id) } + assert_raise(ActiveRecord::RecordNotFound) { Firm.find_by!(id: foo.id) } # Nest FirmOnTheFly in the test case where Dependencies won't see it. self.class.const_set :FirmOnTheFly, Class.new(Firm) assert_raise(ActiveRecord::SubclassNotFound) { Firm.find(foo.id) } + assert_raise(ActiveRecord::SubclassNotFound) { Firm.find_by!(id: foo.id) } # Nest FirmOnTheFly in Firm where Dependencies will see it. # This is analogous to nesting models in a migration. @@ -526,6 +528,7 @@ class InheritanceComputeTypeTest < ActiveRecord::TestCase # And instantiate will find the existing constant rather than trying # to require firm_on_the_fly. assert_nothing_raised { assert_kind_of Firm::FirmOnTheFly, Firm.find(foo.id) } + assert_nothing_raised { assert_kind_of Firm::FirmOnTheFly, Firm.find_by!(id: foo.id) } end end diff --git a/activerecord/test/cases/insert_all_test.rb b/activerecord/test/cases/insert_all_test.rb new file mode 100644 index 0000000000..f24c63031c --- /dev/null +++ b/activerecord/test/cases/insert_all_test.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" + +class ReadonlyNameBook < Book + attr_readonly :name +end + +class InsertAllTest < ActiveRecord::TestCase + fixtures :books + + def test_insert + skip unless supports_insert_on_duplicate_skip? + + id = 1_000_000 + + assert_difference "Book.count", +1 do + Book.insert(id: id, name: "Rework", author_id: 1) + end + + Book.upsert(id: id, name: "Remote", author_id: 1) + + assert_equal "Remote", Book.find(id).name + end + + def test_insert! + assert_difference "Book.count", +1 do + Book.insert! name: "Rework", author_id: 1 + end + end + + def test_insert_all + assert_difference "Book.count", +10 do + Book.insert_all! [ + { name: "Rework", author_id: 1 }, + { name: "Patterns of Enterprise Application Architecture", author_id: 1 }, + { name: "Design of Everyday Things", author_id: 1 }, + { name: "Practical Object-Oriented Design in Ruby", author_id: 1 }, + { name: "Clean Code", author_id: 1 }, + { name: "Ruby Under a Microscope", author_id: 1 }, + { name: "The Principles of Product Development Flow", author_id: 1 }, + { name: "Peopleware", author_id: 1 }, + { name: "About Face", author_id: 1 }, + { name: "Eloquent Ruby", author_id: 1 }, + ] + end + end + + def test_insert_all_should_handle_empty_arrays + assert_raise ArgumentError do + Book.insert_all! [] + end + end + + def test_insert_all_raises_on_duplicate_records + assert_raise ActiveRecord::RecordNotUnique do + Book.insert_all! [ + { name: "Rework", author_id: 1 }, + { name: "Patterns of Enterprise Application Architecture", author_id: 1 }, + { name: "Agile Web Development with Rails", author_id: 1 }, + ] + end + end + + def test_insert_all_returns_ActiveRecord_Result + result = Book.insert_all! [{ name: "Rework", author_id: 1 }] + assert_kind_of ActiveRecord::Result, result + end + + def test_insert_all_returns_primary_key_if_returning_is_supported + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }] + assert_equal %w[ id ], result.columns + end + + def test_insert_all_returns_nothing_if_returning_is_empty + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }], returning: [] + assert_equal [], result.columns + end + + def test_insert_all_returns_nothing_if_returning_is_false + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }], returning: false + assert_equal [], result.columns + end + + def test_insert_all_returns_requested_fields + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }], returning: [:id, :name] + assert_equal %w[ Rework ], result.pluck("name") + end + + def test_insert_all_can_skip_duplicate_records + skip unless supports_insert_on_duplicate_skip? + + assert_no_difference "Book.count" do + Book.insert_all [{ id: 1, name: "Agile Web Development with Rails" }] + end + end + + def test_insert_all_with_skip_duplicates_and_autonumber_id_not_given + skip unless supports_insert_on_duplicate_skip? + + assert_difference "Book.count", 1 do + # These two books are duplicates according to an index on %i[author_id name] + # but their IDs are not specified so they will be assigned different IDs + # by autonumber. We will get an exception from MySQL if we attempt to skip + # one of these records by assigning its ID. + Book.insert_all [ + { author_id: 8, name: "Refactoring" }, + { author_id: 8, name: "Refactoring" } + ] + end + end + + def test_insert_all_with_skip_duplicates_and_autonumber_id_given + skip unless supports_insert_on_duplicate_skip? + + assert_difference "Book.count", 1 do + Book.insert_all [ + { id: 200, author_id: 8, name: "Refactoring" }, + { id: 201, author_id: 8, name: "Refactoring" } + ] + end + end + + def test_skip_duplicates_strategy_does_not_secretly_upsert + skip unless supports_insert_on_duplicate_skip? + + book = Book.create!(author_id: 8, name: "Refactoring", format: "EXPECTED") + + assert_no_difference "Book.count" do + Book.insert(author_id: 8, name: "Refactoring", format: "UNEXPECTED") + end + + assert_equal "EXPECTED", book.reload.format + end + + def test_insert_all_will_raise_if_duplicates_are_skipped_only_for_a_certain_conflict_target + skip unless supports_insert_on_duplicate_skip? && supports_insert_conflict_target? + + assert_raise ActiveRecord::RecordNotUnique do + Book.insert_all [{ id: 1, name: "Agile Web Development with Rails" }], + unique_by: :index_books_on_author_id_and_name + end + end + + def test_insert_all_and_upsert_all_with_index_finding_options + skip unless supports_insert_conflict_target? + + assert_difference "Book.count", +3 do + Book.insert_all [{ name: "Rework", author_id: 1 }], unique_by: :isbn + Book.insert_all [{ name: "Remote", author_id: 1 }], unique_by: %i( author_id name ) + Book.insert_all [{ name: "Renote", author_id: 1 }], unique_by: :index_books_on_isbn + end + + assert_raise ActiveRecord::RecordNotUnique do + Book.upsert_all [{ name: "Rework", author_id: 1 }], unique_by: :isbn + end + end + + def test_insert_all_and_upsert_all_raises_when_index_is_missing + skip unless supports_insert_conflict_target? + + [ :cats, %i( author_id isbn ), :author_id ].each do |missing_or_non_unique_by| + error = assert_raises ArgumentError do + Book.insert_all [{ name: "Rework", author_id: 1 }], unique_by: missing_or_non_unique_by + end + assert_match "No unique index", error.message + + error = assert_raises ArgumentError do + Book.upsert_all [{ name: "Rework", author_id: 1 }], unique_by: missing_or_non_unique_by + end + assert_match "No unique index", error.message + end + end + + def test_insert_logs_message_including_model_name + skip unless supports_insert_conflict_target? + + capture_log_output do |output| + Book.insert(name: "Rework", author_id: 1) + assert_match "Book Insert", output.string + end + end + + def test_insert_all_logs_message_including_model_name + skip unless supports_insert_conflict_target? + + capture_log_output do |output| + Book.insert_all [{ name: "Remote", author_id: 1 }, { name: "Renote", author_id: 1 }] + assert_match "Book Bulk Insert", output.string + end + end + + def test_upsert_logs_message_including_model_name + skip unless supports_insert_on_duplicate_update? + + capture_log_output do |output| + Book.upsert(name: "Remote", author_id: 1) + assert_match "Book Upsert", output.string + end + end + + def test_upsert_all_logs_message_including_model_name + skip unless supports_insert_on_duplicate_update? + + capture_log_output do |output| + Book.upsert_all [{ name: "Remote", author_id: 1 }, { name: "Renote", author_id: 1 }] + assert_match "Book Bulk Upsert", output.string + end + end + + def test_upsert_all_updates_existing_records + skip unless supports_insert_on_duplicate_update? + + new_name = "Agile Web Development with Rails, 4th Edition" + Book.upsert_all [{ id: 1, name: new_name }] + assert_equal new_name, Book.find(1).name + end + + def test_upsert_all_does_not_update_readonly_attributes + skip unless supports_insert_on_duplicate_update? + + new_name = "Agile Web Development with Rails, 4th Edition" + ReadonlyNameBook.upsert_all [{ id: 1, name: new_name }] + assert_not_equal new_name, Book.find(1).name + end + + def test_upsert_all_does_not_update_primary_keys + skip unless supports_insert_on_duplicate_update? && supports_insert_conflict_target? + + Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7 }] + Book.upsert_all [{ id: 103, name: "Perelandra", author_id: 7, isbn: "1974522598" }], + unique_by: :index_books_on_author_id_and_name + + book = Book.find_by(name: "Perelandra") + assert_equal 101, book.id, "Should not have updated the ID" + assert_equal "1974522598", book.isbn, "Should have updated the isbn" + end + + def test_upsert_all_does_not_perform_an_upsert_if_a_partial_index_doesnt_apply + skip unless supports_insert_on_duplicate_update? && supports_insert_conflict_target? && supports_partial_index? + + Book.upsert_all [{ name: "Out of the Silent Planet", author_id: 7, isbn: "1974522598", published_on: Date.new(1938, 4, 1) }] + Book.upsert_all [{ name: "Perelandra", author_id: 7, isbn: "1974522598" }], + unique_by: :index_books_on_isbn + + assert_equal ["Out of the Silent Planet", "Perelandra"], Book.where(isbn: "1974522598").order(:name).pluck(:name) + end + + def test_insert_all_raises_on_unknown_attribute + assert_raise ActiveRecord::UnknownAttributeError do + Book.insert_all! [{ unknown_attribute: "Test" }] + end + end + + private + + def capture_log_output + output = StringIO.new + old_logger, ActiveRecord::Base.logger = ActiveRecord::Base.logger, ActiveSupport::Logger.new(output) + + begin + yield output + ensure + ActiveRecord::Base.logger = old_logger + end + end +end diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb index c09ea32991..06382b6c7c 100644 --- a/activerecord/test/cases/instrumentation_test.rb +++ b/activerecord/test/cases/instrumentation_test.rb @@ -41,8 +41,8 @@ module ActiveRecord assert_equal "Book Update", event.payload[:name] end end - book = Book.create(name: "test book") - book.update_attribute(:name, "new name") + book = Book.create(name: "test book", format: "paperback") + book.update_attribute(:format, "ebook") ensure ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber end @@ -54,8 +54,8 @@ module ActiveRecord assert_equal "Book Update All", event.payload[:name] end end - Book.create(name: "test book") - Book.update_all(name: "new name") + Book.create(name: "test book", format: "paperback") + Book.update_all(format: "ebook") ensure ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber end diff --git a/activerecord/test/cases/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb index 6cf17ac15d..7f67b945f0 100644 --- a/activerecord/test/cases/invertible_migration_test.rb +++ b/activerecord/test/cases/invertible_migration_test.rb @@ -103,6 +103,32 @@ module ActiveRecord end end + class ChangeColumnComment1 < SilentMigration + def change + create_table("horses") do |t| + t.column :name, :string, comment: "Sekitoba" + end + end + end + + class ChangeColumnComment2 < SilentMigration + def change + change_column_comment :horses, :name, from: "Sekitoba", to: "Diomed" + end + end + + class ChangeTableComment1 < SilentMigration + def change + create_table("horses", comment: "Sekitoba") + end + end + + class ChangeTableComment2 < SilentMigration + def change + change_table_comment :horses, from: "Sekitoba", to: "Diomed" + end + end + class DisableExtension1 < SilentMigration def change enable_extension "hstore" @@ -290,6 +316,7 @@ module ActiveRecord def test_migrate_revert_change_column_default migration1 = ChangeColumnDefault1.new migration1.migrate(:up) + Horse.reset_column_information assert_equal "Sekitoba", Horse.new.name migration2 = ChangeColumnDefault2.new @@ -302,12 +329,46 @@ module ActiveRecord assert_equal "Sekitoba", Horse.new.name end + if ActiveRecord::Base.connection.supports_comments? + def test_migrate_revert_change_column_comment + migration1 = ChangeColumnComment1.new + migration1.migrate(:up) + Horse.reset_column_information + assert_equal "Sekitoba", Horse.columns_hash["name"].comment + + migration2 = ChangeColumnComment2.new + migration2.migrate(:up) + Horse.reset_column_information + assert_equal "Diomed", Horse.columns_hash["name"].comment + + migration2.migrate(:down) + Horse.reset_column_information + assert_equal "Sekitoba", Horse.columns_hash["name"].comment + end + + def test_migrate_revert_change_table_comment + connection = ActiveRecord::Base.connection + migration1 = ChangeTableComment1.new + migration1.migrate(:up) + assert_equal "Sekitoba", connection.table_comment("horses") + + migration2 = ChangeTableComment2.new + migration2.migrate(:up) + assert_equal "Diomed", connection.table_comment("horses") + + migration2.migrate(:down) + assert_equal "Sekitoba", connection.table_comment("horses") + end + end + if current_adapter?(:PostgreSQLAdapter) def test_migrate_enable_and_disable_extension migration1 = InvertibleMigration.new migration2 = DisableExtension1.new migration3 = DisableExtension2.new + assert_equal true, Horse.connection.extension_available?("hstore") + migration1.migrate(:up) migration2.migrate(:up) assert_equal true, Horse.connection.extension_enabled?("hstore") diff --git a/activerecord/test/cases/legacy_configurations_test.rb b/activerecord/test/cases/legacy_configurations_test.rb deleted file mode 100644 index c36feb5116..0000000000 --- a/activerecord/test/cases/legacy_configurations_test.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require "cases/helper" - -module ActiveRecord - class LegacyConfigurationsTest < ActiveRecord::TestCase - def test_can_turn_configurations_into_a_hash - assert ActiveRecord::Base.configurations.to_h.is_a?(Hash), "expected to be a hash but was not." - assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"].sort, ActiveRecord::Base.configurations.to_h.keys.sort - end - - def test_each_is_deprecated - assert_deprecated do - ActiveRecord::Base.configurations.each do |db_config| - assert_equal "primary", db_config.spec_name - end - end - end - - def test_first_is_deprecated - assert_deprecated do - db_config = ActiveRecord::Base.configurations.first - assert_equal "arunit", db_config.env_name - assert_equal "primary", db_config.spec_name - end - end - - def test_fetch_is_deprecated - assert_deprecated do - db_config = ActiveRecord::Base.configurations.fetch("arunit").first - assert_equal "arunit", db_config.env_name - assert_equal "primary", db_config.spec_name - end - end - - def test_values_are_deprecated - config_hashes = ActiveRecord::Base.configurations.configurations.map(&:config) - assert_deprecated do - assert_equal config_hashes, ActiveRecord::Base.configurations.values - end - end - end -end diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb index 33bd74e114..04f9b26960 100644 --- a/activerecord/test/cases/locking_test.rb +++ b/activerecord/test/cases/locking_test.rb @@ -182,7 +182,9 @@ class OptimisticLockingTest < ActiveRecord::TestCase p1.touch assert_equal 1, p1.lock_version - assert_not p1.changed?, "Changes should have been cleared" + assert_not_predicate p1, :changed?, "Changes should have been cleared" + assert_predicate p1, :saved_changes? + assert_equal ["lock_version", "updated_at"], p1.saved_changes.keys.sort end def test_touch_stale_object @@ -193,6 +195,8 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_raises(ActiveRecord::StaleObjectError) do stale_person.touch end + + assert_not_predicate stale_person, :saved_changes? end def test_update_with_dirty_primary_key @@ -296,6 +300,9 @@ class OptimisticLockingTest < ActiveRecord::TestCase t1.touch assert_equal 1, t1.lock_version + assert_not_predicate t1, :changed? + assert_predicate t1, :saved_changes? + assert_equal ["lock_version", "updated_at"], t1.saved_changes.keys.sort end def test_touch_stale_object_with_lock_without_default @@ -307,6 +314,8 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_raises(ActiveRecord::StaleObjectError) do stale_object.touch end + + assert_not_predicate stale_object, :saved_changes? end def test_lock_without_default_should_work_with_null_in_the_database diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb index 7777508349..cc0587fa50 100644 --- a/activerecord/test/cases/migration/change_schema_test.rb +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -462,7 +462,11 @@ module ActiveRecord end def test_create_table_with_force_cascade_drops_dependent_objects - skip "MySQL > 5.5 does not drop dependent objects with DROP TABLE CASCADE" if current_adapter?(:Mysql2Adapter) + if current_adapter?(:Mysql2Adapter) + skip "MySQL > 5.5 does not drop dependent objects with DROP TABLE CASCADE" + elsif current_adapter?(:SQLite3Adapter) + skip "SQLite3 does not support DROP TABLE CASCADE syntax" + end # can't re-create table referenced by foreign key assert_raises(ActiveRecord::StatementInvalid) do @connection.create_table :trains, force: true diff --git a/activerecord/test/cases/migration/column_attributes_test.rb b/activerecord/test/cases/migration/column_attributes_test.rb index 3022121f4c..b6064500ee 100644 --- a/activerecord/test/cases/migration/column_attributes_test.rb +++ b/activerecord/test/cases/migration/column_attributes_test.rb @@ -176,11 +176,9 @@ module ActiveRecord if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) def test_out_of_range_limit_should_raise - assert_raise(ActiveRecordError) { add_column :test_models, :integer_too_big, :integer, limit: 10 } - - unless current_adapter?(:PostgreSQLAdapter) - assert_raise(ActiveRecordError) { add_column :test_models, :text_too_big, :text, limit: 0xfffffffff } - end + assert_raise(ArgumentError) { add_column :test_models, :integer_too_big, :integer, limit: 10 } + assert_raise(ArgumentError) { add_column :test_models, :text_too_big, :text, limit: 0xfffffffff } + assert_raise(ArgumentError) { add_column :test_models, :binary_too_big, :binary, limit: 0xfffffffff } end end end diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb index cedd9c44e3..cce3461e18 100644 --- a/activerecord/test/cases/migration/columns_test.rb +++ b/activerecord/test/cases/migration/columns_test.rb @@ -109,18 +109,10 @@ module ActiveRecord add_index "test_models", ["hat_style", "hat_size"], unique: true rename_column "test_models", "hat_size", "size" - if current_adapter? :OracleAdapter - assert_equal ["i_test_models_hat_style_size"], connection.indexes("test_models").map(&:name) - else - assert_equal ["index_test_models_on_hat_style_and_size"], connection.indexes("test_models").map(&:name) - end + assert_equal ["index_test_models_on_hat_style_and_size"], connection.indexes("test_models").map(&:name) rename_column "test_models", "hat_style", "style" - if current_adapter? :OracleAdapter - assert_equal ["i_test_models_style_size"], connection.indexes("test_models").map(&:name) - else - assert_equal ["index_test_models_on_style_and_size"], connection.indexes("test_models").map(&:name) - end + assert_equal ["index_test_models_on_style_and_size"], connection.indexes("test_models").map(&:name) end def test_rename_column_does_not_rename_custom_named_index @@ -144,7 +136,7 @@ module ActiveRecord def test_remove_column_with_multi_column_index # MariaDB starting with 10.2.8 # Dropping a column that is part of a multi-column UNIQUE constraint is not permitted. - skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.version >= "10.2.8" + skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.database_version >= "10.2.8" add_column "test_models", :hat_size, :integer add_column "test_models", :hat_style, :string, limit: 100 @@ -318,6 +310,17 @@ module ActiveRecord ensure connection.drop_table(:my_table) rescue nil end + + def test_add_column_without_column_name + e = assert_raise ArgumentError do + connection.create_table "my_table", force: true do |t| + t.timestamp + end + end + assert_equal "Missing column name(s) for timestamp", e.message + ensure + connection.drop_table :my_table, if_exists: true + end end end end diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb index 01f8628fc5..c9f3756b1f 100644 --- a/activerecord/test/cases/migration/command_recorder_test.rb +++ b/activerecord/test/cases/migration/command_recorder_test.rb @@ -182,6 +182,40 @@ module ActiveRecord assert_equal [:change_column_default, [:table, :column, from: false, to: true]], change end + if ActiveRecord::Base.connection.supports_comments? + def test_invert_change_column_comment + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :change_column_comment, [:table, :column, "comment"] + end + end + + def test_invert_change_column_comment_with_from_and_to + change = @recorder.inverse_of :change_column_comment, [:table, :column, from: "old_value", to: "new_value"] + assert_equal [:change_column_comment, [:table, :column, from: "new_value", to: "old_value"]], change + end + + def test_invert_change_column_comment_with_from_and_to_with_nil + change = @recorder.inverse_of :change_column_comment, [:table, :column, from: nil, to: "new_value"] + assert_equal [:change_column_comment, [:table, :column, from: "new_value", to: nil]], change + end + + def test_invert_change_table_comment + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :change_column_comment, [:table, :column, "comment"] + end + end + + def test_invert_change_table_comment_with_from_and_to + change = @recorder.inverse_of :change_table_comment, [:table, from: "old_value", to: "new_value"] + assert_equal [:change_table_comment, [:table, from: "new_value", to: "old_value"]], change + end + + def test_invert_change_table_comment_with_from_and_to_with_nil + change = @recorder.inverse_of :change_table_comment, [:table, from: nil, to: "new_value"] + assert_equal [:change_table_comment, [:table, from: "new_value", to: nil]], change + end + end + def test_invert_change_column_null add = @recorder.inverse_of :change_column_null, [:table, :column, true] assert_equal [:change_column_null, [:table, :column, false]], add diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb index 5753bd7117..726ccf925e 100644 --- a/activerecord/test/cases/migration/compatibility_test.rb +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -220,6 +220,35 @@ module ActiveRecord end end + if ActiveRecord::Base.connection.supports_comments? + def test_change_column_comment_can_be_reverted + migration = Class.new(ActiveRecord::Migration[5.2]) { + def migrate(x) + revert do + change_column_comment(:testings, :foo, "comment") + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + assert connection.column_exists?(:testings, :foo, comment: "comment") + end + + def test_change_table_comment_can_be_reverted + migration = Class.new(ActiveRecord::Migration[5.2]) { + def migrate(x) + revert do + change_table_comment(:testings, "comment") + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert_equal "comment", connection.table_comment("testings") + end + end + if current_adapter?(:PostgreSQLAdapter) class Testing < ActiveRecord::Base end @@ -242,9 +271,9 @@ module ActiveRecord private def precision_implicit_default if current_adapter?(:Mysql2Adapter) - { presicion: 0 } + { precision: 0 } else - { presicion: nil } + { precision: nil } end end end diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index bb233fbf74..5f1057f093 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -3,7 +3,7 @@ require "cases/helper" require "support/schema_dumping_helper" -if ActiveRecord::Base.connection.supports_foreign_keys_in_create? +if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ForeignKeyInCreateTest < ActiveRecord::TestCase @@ -31,24 +31,39 @@ if ActiveRecord::Base.connection.supports_foreign_keys_in_create? belongs_to :rocket end - setup do - @connection = ActiveRecord::Base.connection - @connection.create_table "rockets", force: true do |t| - t.string :name + class CreateRocketsMigration < ActiveRecord::Migration::Current + def up + create_table :rockets do |t| + t.string :name + end + + create_table :astronauts do |t| + t.string :name + t.references :rocket, foreign_key: true + end end - @connection.create_table "astronauts", force: true do |t| - t.string :name - t.references :rocket, foreign_key: true + def down + drop_table :astronauts, if_exists: true + drop_table :rockets, if_exists: true end + end + + def setup + @connection = ActiveRecord::Base.connection + @migration = CreateRocketsMigration.new + silence_stream($stdout) { @migration.migrate(:up) } + Rocket.reset_table_name Rocket.reset_column_information + Astronaut.reset_table_name Astronaut.reset_column_information end - teardown do - @connection.drop_table "astronauts", if_exists: true - @connection.drop_table "rockets", if_exists: true + def teardown + silence_stream($stdout) { @migration.migrate(:down) } + Rocket.reset_table_name Rocket.reset_column_information + Astronaut.reset_table_name Astronaut.reset_column_information end @@ -56,53 +71,100 @@ if ActiveRecord::Base.connection.supports_foreign_keys_in_create? rocket = Rocket.create!(name: "myrocket") rocket.astronauts << Astronaut.create! - @connection.change_column_null :rockets, :name, false + @connection.change_column_null Rocket.table_name, :name, false - foreign_keys = @connection.foreign_keys("astronauts") + foreign_keys = @connection.foreign_keys(Astronaut.table_name) assert_equal 1, foreign_keys.size fk = foreign_keys.first assert_equal "myrocket", Rocket.first.name - assert_equal "astronauts", fk.from_table - assert_equal "rockets", fk.to_table + assert_equal Astronaut.table_name, fk.from_table + assert_equal Rocket.table_name, fk.to_table end def test_rename_column_of_child_table rocket = Rocket.create!(name: "myrocket") rocket.astronauts << Astronaut.create! - @connection.rename_column :astronauts, :name, :astronaut_name + @connection.rename_column Astronaut.table_name, :name, :astronaut_name - foreign_keys = @connection.foreign_keys("astronauts") + foreign_keys = @connection.foreign_keys(Astronaut.table_name) assert_equal 1, foreign_keys.size fk = foreign_keys.first assert_equal "myrocket", Rocket.first.name - assert_equal "astronauts", fk.from_table - assert_equal "rockets", fk.to_table + assert_equal Astronaut.table_name, fk.from_table + assert_equal Rocket.table_name, fk.to_table end def test_rename_reference_column_of_child_table rocket = Rocket.create!(name: "myrocket") rocket.astronauts << Astronaut.create! - @connection.rename_column :astronauts, :rocket_id, :new_rocket_id + @connection.rename_column Astronaut.table_name, :rocket_id, :new_rocket_id - foreign_keys = @connection.foreign_keys("astronauts") + foreign_keys = @connection.foreign_keys(Astronaut.table_name) assert_equal 1, foreign_keys.size fk = foreign_keys.first assert_equal "myrocket", Rocket.first.name - assert_equal "astronauts", fk.from_table - assert_equal "rockets", fk.to_table + assert_equal Astronaut.table_name, fk.from_table + assert_equal Rocket.table_name, fk.to_table assert_equal "new_rocket_id", fk.options[:column] end + + def test_remove_reference_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.remove_column Astronaut.table_name, :rocket_id + + assert_empty @connection.foreign_keys(Astronaut.table_name) + end + + def test_remove_foreign_key_by_column + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.remove_foreign_key Astronaut.table_name, column: :rocket_id + + assert_empty @connection.foreign_keys(Astronaut.table_name) + end + + def test_remove_foreign_key_by_column_in_change_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.change_table Astronaut.table_name do |t| + t.remove_foreign_key column: :rocket_id + end + + assert_empty @connection.foreign_keys(Astronaut.table_name) + end + end + + class ForeignKeyChangeColumnWithPrefixTest < ForeignKeyChangeColumnTest + setup do + ActiveRecord::Base.table_name_prefix = "p_" + end + + teardown do + ActiveRecord::Base.table_name_prefix = nil + end + end + + class ForeignKeyChangeColumnWithSuffixTest < ForeignKeyChangeColumnTest + setup do + ActiveRecord::Base.table_name_suffix = "_s" + end + + teardown do + ActiveRecord::Base.table_name_suffix = nil + end end end end -end -if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ForeignKeyTest < ActiveRecord::TestCase @@ -141,7 +203,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "fk_test_has_pk", fk.to_table assert_equal "fk_id", fk.column assert_equal "pk_id", fk.primary_key - assert_equal "fk_name", fk.name + assert_equal "fk_name", fk.name unless current_adapter?(:SQLite3Adapter) end def test_add_foreign_key_inferes_column @@ -155,7 +217,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "rockets", fk.to_table assert_equal "rocket_id", fk.column assert_equal "id", fk.primary_key - assert_equal("fk_rails_78146ddd2e", fk.name) + assert_equal "fk_rails_78146ddd2e", fk.name unless current_adapter?(:SQLite3Adapter) end def test_add_foreign_key_with_column @@ -169,7 +231,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "rockets", fk.to_table assert_equal "rocket_id", fk.column assert_equal "id", fk.primary_key - assert_equal("fk_rails_78146ddd2e", fk.name) + assert_equal "fk_rails_78146ddd2e", fk.name unless current_adapter?(:SQLite3Adapter) end def test_add_foreign_key_with_non_standard_primary_key @@ -188,7 +250,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "space_shuttles", fk.to_table assert_equal "pk", fk.primary_key ensure - @connection.remove_foreign_key :astronauts, name: "custom_pk" + @connection.remove_foreign_key :astronauts, name: "custom_pk", to_table: "space_shuttles" @connection.drop_table :space_shuttles end @@ -262,6 +324,8 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_foreign_key_exists_by_name + skip if current_adapter?(:SQLite3Adapter) + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk" assert @connection.foreign_key_exists?(:astronauts, name: "fancy_named_fk") @@ -293,6 +357,8 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_remove_foreign_key_by_name + skip if current_adapter?(:SQLite3Adapter) + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk" assert_equal 1, @connection.foreign_keys("astronauts").size @@ -301,9 +367,22 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_remove_foreign_non_existing_foreign_key_raises - assert_raises ArgumentError do + e = assert_raises ArgumentError do @connection.remove_foreign_key :astronauts, :rockets end + assert_equal "Table 'astronauts' has no foreign key for rockets", e.message + end + + def test_remove_foreign_key_by_the_select_one_on_the_same_table + @connection.add_foreign_key :astronauts, :rockets + @connection.add_reference :astronauts, :myrocket, foreign_key: { to_table: :rockets } + + assert_equal 2, @connection.foreign_keys("astronauts").size + + @connection.remove_foreign_key :astronauts, :rockets, column: "myrocket_id" + + assert_equal [["astronauts", "rockets", "rocket_id"]], + @connection.foreign_keys("astronauts").map { |fk| [fk.from_table, fk.to_table, fk.column] } end if ActiveRecord::Base.connection.supports_validate_constraints? @@ -382,7 +461,11 @@ if ActiveRecord::Base.connection.supports_foreign_keys? def test_schema_dumping_with_options output = dump_table_schema "fk_test_has_fk" - assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output + if current_adapter?(:SQLite3Adapter) + assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id"$}, output + else + assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output + end end def test_schema_dumping_with_custom_fk_ignore_pattern @@ -436,7 +519,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end class CreateSchoolsAndClassesMigration < ActiveRecord::Migration::Current - def change + def up create_table(:schools) create_table(:classes) do |t| @@ -444,6 +527,11 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end add_foreign_key :classes, :schools end + + def down + drop_table :classes, if_exists: true + drop_table :schools, if_exists: true + end end def test_add_foreign_key_with_prefix @@ -468,30 +556,4 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end end -else - module ActiveRecord - class Migration - class NoForeignKeySupportTest < ActiveRecord::TestCase - setup do - @connection = ActiveRecord::Base.connection - end - - def test_add_foreign_key_should_be_noop - @connection.add_foreign_key :clubs, :categories - end - - def test_remove_foreign_key_should_be_noop - @connection.remove_foreign_key :clubs, :categories - end - - unless current_adapter?(:SQLite3Adapter) - def test_foreign_keys_should_raise_not_implemented - assert_raises NotImplementedError do - @connection.foreign_keys("clubs") - end - end - end - end - end - end end diff --git a/activerecord/test/cases/migration/index_test.rb b/activerecord/test/cases/migration/index_test.rb index f8fecc83cd..5e688efc2b 100644 --- a/activerecord/test/cases/migration/index_test.rb +++ b/activerecord/test/cases/migration/index_test.rb @@ -158,14 +158,11 @@ module ActiveRecord connection.add_index("testings", ["last_name", "first_name"]) connection.remove_index("testings", column: ["last_name", "first_name"]) - # Oracle adapter cannot have specified index name larger than 30 characters - # Oracle adapter is shortening index name when just column list is given - unless current_adapter?(:OracleAdapter) - connection.add_index("testings", ["last_name", "first_name"]) - connection.remove_index("testings", name: :index_testings_on_last_name_and_first_name) - connection.add_index("testings", ["last_name", "first_name"]) - connection.remove_index("testings", "last_name_and_first_name") - end + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", name: :index_testings_on_last_name_and_first_name) + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", "last_name_and_first_name") + connection.add_index("testings", ["last_name", "first_name"]) connection.remove_index("testings", ["last_name", "first_name"]) diff --git a/activerecord/test/cases/migration/references_foreign_key_test.rb b/activerecord/test/cases/migration/references_foreign_key_test.rb index 620e9ab6ca..90a50a5651 100644 --- a/activerecord/test/cases/migration/references_foreign_key_test.rb +++ b/activerecord/test/cases/migration/references_foreign_key_test.rb @@ -2,7 +2,7 @@ require "cases/helper" -if ActiveRecord::Base.connection.supports_foreign_keys_in_create? +if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ReferencesForeignKeyInCreateTest < ActiveRecord::TestCase @@ -65,9 +65,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys_in_create? end end end -end -if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ReferencesForeignKeyTest < ActiveRecord::TestCase @@ -172,13 +170,18 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end class CreateDogsMigration < ActiveRecord::Migration::Current - def change + def up create_table :dog_owners create_table :dogs do |t| t.references :dog_owner, foreign_key: true end end + + def down + drop_table :dogs, if_exists: true + drop_table :dog_owners, if_exists: true + end end def test_references_foreign_key_with_prefix @@ -232,24 +235,4 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end end -else - class ReferencesWithoutForeignKeySupportTest < ActiveRecord::TestCase - setup do - @connection = ActiveRecord::Base.connection - @connection.create_table(:testing_parents, force: true) - end - - teardown do - @connection.drop_table("testings", if_exists: true) - @connection.drop_table("testing_parents", if_exists: true) - end - - test "ignores foreign keys defined with the table" do - @connection.create_table :testings do |t| - t.references :testing_parent, foreign_key: true - end - - assert_includes @connection.data_sources, "testings" - end - end end diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index 02031e51ef..255885b599 100644 --- a/activerecord/test/cases/migration_test.rb +++ b/activerecord/test/cases/migration_test.rb @@ -385,6 +385,7 @@ class MigrationTest < ActiveRecord::TestCase assert_equal "changed", ActiveRecord::SchemaMigration.table_name ensure ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name + ActiveRecord::SchemaMigration.reset_table_name Reminder.reset_table_name end @@ -405,6 +406,7 @@ class MigrationTest < ActiveRecord::TestCase assert_equal "changed", ActiveRecord::InternalMetadata.table_name ensure ActiveRecord::Base.internal_metadata_table_name = original_internal_metadata_table_name + ActiveRecord::InternalMetadata.reset_table_name Reminder.reset_table_name end @@ -567,66 +569,57 @@ class MigrationTest < ActiveRecord::TestCase end end - if current_adapter? :OracleAdapter - def test_create_table_with_custom_sequence_name - # table name is 29 chars, the standard sequence name will - # be 33 chars and should be shortened - assert_nothing_raised do - Person.connection.create_table :table_with_name_thats_just_ok do |t| - t.column :foo, :string, null: false - end - ensure - Person.connection.drop_table :table_with_name_thats_just_ok rescue nil - end - - # should be all good w/ a custom sequence name - assert_nothing_raised do - Person.connection.create_table :table_with_name_thats_just_ok, - sequence_name: "suitably_short_seq" do |t| - t.column :foo, :string, null: false - end - - Person.connection.execute("select suitably_short_seq.nextval from dual") - - ensure - Person.connection.drop_table :table_with_name_thats_just_ok, - sequence_name: "suitably_short_seq" rescue nil - end - - # confirm the custom sequence got dropped - assert_raise(ActiveRecord::StatementInvalid) do - Person.connection.execute("select suitably_short_seq.nextval from dual") + def test_decimal_scale_without_precision_should_raise + e = assert_raise(ArgumentError) do + Person.connection.create_table :test_decimal_scales, force: true do |t| + t.decimal :scaleonly, scale: 10 end end + + assert_equal "Error adding decimal column: precision cannot be empty if scale is specified", e.message + ensure + Person.connection.drop_table :test_decimal_scales, if_exists: true end if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) def test_out_of_range_integer_limit_should_raise - e = assert_raise(ActiveRecord::ActiveRecordError, "integer limit didn't raise") do + e = assert_raise(ArgumentError) do Person.connection.create_table :test_integer_limits, force: true do |t| t.column :bigone, :integer, limit: 10 end end - assert_match(/No integer type has byte size 10/, e.message) + assert_includes e.message, "No integer type has byte size 10" ensure Person.connection.drop_table :test_integer_limits, if_exists: true end - end - if current_adapter?(:Mysql2Adapter) def test_out_of_range_text_limit_should_raise - e = assert_raise(ActiveRecord::ActiveRecordError, "text limit didn't raise") do + e = assert_raise(ArgumentError) do Person.connection.create_table :test_text_limits, force: true do |t| t.text :bigtext, limit: 0xfffffffff end end - assert_match(/No text type has byte length #{0xfffffffff}/, e.message) + assert_includes e.message, "No text type has byte size #{0xfffffffff}" ensure Person.connection.drop_table :test_text_limits, if_exists: true end + def test_out_of_range_binary_limit_should_raise + e = assert_raise(ArgumentError) do + Person.connection.create_table :test_binary_limits, force: true do |t| + t.binary :bigbinary, limit: 0xfffffffff + end + end + + assert_includes e.message, "No binary type has byte size #{0xfffffffff}" + ensure + Person.connection.drop_table :test_binary_limits, if_exists: true + end + end + + if current_adapter?(:Mysql2Adapter) def test_invalid_text_size_should_raise e = assert_raise(ArgumentError) do Person.connection.create_table :test_text_sizes, force: true do |t| @@ -634,7 +627,7 @@ class MigrationTest < ActiveRecord::TestCase end end - assert_match(/#{0xfffffffff} is invalid :size value\. Only :tiny, :medium, and :long are allowed\./, e.message) + assert_equal "#{0xfffffffff} is invalid :size value. Only :tiny, :medium, and :long are allowed.", e.message ensure Person.connection.drop_table :test_text_sizes, if_exists: true end @@ -861,7 +854,7 @@ if ActiveRecord::Base.connection.supports_bulk_alter? classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] expected_query_count = { - "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not + "Mysql2Adapter" => 1, # mysql2 supports creating two indexes using one statement "PostgreSQLAdapter" => 2, }.fetch(classname) { raise "need an expected query count for #{classname}" @@ -893,7 +886,7 @@ if ActiveRecord::Base.connection.supports_bulk_alter? classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] expected_query_count = { - "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not + "Mysql2Adapter" => 1, # mysql2 supports dropping and creating two indexes using one statement "PostgreSQLAdapter" => 2, }.fetch(classname) { raise "need an expected query count for #{classname}" diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb index d5057ad381..7b7aa7e9b7 100644 --- a/activerecord/test/cases/persistence_test.rb +++ b/activerecord/test/cases/persistence_test.rb @@ -183,7 +183,7 @@ class PersistenceTest < ActiveRecord::TestCase assert_not_predicate company, :valid? original_errors = company.errors client = company.becomes(Client) - assert_equal original_errors.keys, client.errors.keys + assert_equal assert_deprecated { original_errors.keys }, assert_deprecated { client.errors.keys } end def test_becomes_errors_base @@ -197,7 +197,7 @@ class PersistenceTest < ActiveRecord::TestCase admin.errors.add :token, :invalid child = admin.becomes(child_class) - assert_equal [:token], child.errors.keys + assert_equal [:token], assert_deprecated { child.errors.keys } assert_nothing_raised do child.errors.add :foo, :invalid end diff --git a/activerecord/test/cases/primary_keys_test.rb b/activerecord/test/cases/primary_keys_test.rb index 4759d3b6b2..511d7fc982 100644 --- a/activerecord/test/cases/primary_keys_test.rb +++ b/activerecord/test/cases/primary_keys_test.rb @@ -203,6 +203,14 @@ class PrimaryKeysTest < ActiveRecord::TestCase assert_queries(3, ignore_none: true) { klass.create! } end + def test_assign_id_raises_error_if_primary_key_doesnt_exist + klass = Class.new(ActiveRecord::Base) do + self.table_name = "dashboards" + end + dashboard = klass.new + assert_raises(ActiveModel::MissingAttributeError) { dashboard.id = "1" } + end + if current_adapter?(:PostgreSQLAdapter) def test_serial_with_quoted_sequence_name column = MixedCaseMonkey.columns_hash[MixedCaseMonkey.primary_key] diff --git a/activerecord/test/cases/relation/delegation_test.rb b/activerecord/test/cases/relation/delegation_test.rb index 63ae438de3..085006c9a2 100644 --- a/activerecord/test/cases/relation/delegation_test.rb +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -44,26 +44,28 @@ module ActiveRecord end class QueryingMethodsDelegationTest < ActiveRecord::TestCase - QUERYING_METHODS = [ - :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, - :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, - :first_or_create, :first_or_create!, :first_or_initialize, - :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, - :find_by, :find_by!, - :destroy_all, :delete_all, :update_all, - :find_each, :find_in_batches, :in_batches, - :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, - :having, :create_with, :distinct, :references, :none, :unscope, :merge, - :count, :average, :minimum, :maximum, :sum, :calculate, - :pluck, :pick, :ids, - ] + QUERYING_METHODS = + ActiveRecord::Batches.public_instance_methods(false) + + ActiveRecord::Calculations.public_instance_methods(false) + + ActiveRecord::FinderMethods.public_instance_methods(false) - [:raise_record_not_found_exception!] + + ActiveRecord::SpawnMethods.public_instance_methods(false) - [:spawn, :merge!] + + ActiveRecord::QueryMethods.public_instance_methods(false).reject { |method| + method.to_s.end_with?("=", "!", "value", "values", "clause") + } - [:reverse_order, :arel, :extensions, :construct_join_dependency] + [ + :any?, :many?, :none?, :one?, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, + :create_or_find_by, :create_or_find_by!, + :destroy_all, :delete_all, :update_all, :touch_all, :delete_by, :destroy_by + ] def test_delegate_querying_methods klass = Class.new(ActiveRecord::Base) do self.table_name = "posts" end + assert_equal QUERYING_METHODS.sort, ActiveRecord::Querying::QUERYING_METHODS.sort + QUERYING_METHODS.each do |method| assert_respond_to klass.all, method assert_respond_to klass, method diff --git a/activerecord/test/cases/relation/delete_all_test.rb b/activerecord/test/cases/relation/delete_all_test.rb index 446d7621ea..d1c13fa1b5 100644 --- a/activerecord/test/cases/relation/delete_all_test.rb +++ b/activerecord/test/cases/relation/delete_all_test.rb @@ -80,25 +80,23 @@ class DeleteAllTest < ActiveRecord::TestCase assert_equal pets.count, pets.delete_all end - unless current_adapter?(:OracleAdapter) - def test_delete_all_with_order_and_limit_deletes_subset_only - author = authors(:david) - limited_posts = Post.where(author: author).order(:id).limit(1) - assert_equal 1, limited_posts.size - assert_equal 2, limited_posts.limit(2).size - assert_equal 1, limited_posts.delete_all - assert_raise(ActiveRecord::RecordNotFound) { posts(:welcome) } - assert posts(:thinking) - end + def test_delete_all_with_order_and_limit_deletes_subset_only + author = authors(:david) + limited_posts = Post.where(author: author).order(:id).limit(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { posts(:welcome) } + assert posts(:thinking) + end - def test_delete_all_with_order_and_limit_and_offset_deletes_subset_only - author = authors(:david) - limited_posts = Post.where(author: author).order(:id).limit(1).offset(1) - assert_equal 1, limited_posts.size - assert_equal 2, limited_posts.limit(2).size - assert_equal 1, limited_posts.delete_all - assert_raise(ActiveRecord::RecordNotFound) { posts(:thinking) } - assert posts(:welcome) - end + def test_delete_all_with_order_and_limit_and_offset_deletes_subset_only + author = authors(:david) + limited_posts = Post.where(author: author).order(:id).limit(1).offset(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { posts(:thinking) } + assert posts(:welcome) end end diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb index 224e4f39a8..5c5e760e34 100644 --- a/activerecord/test/cases/relation/merging_test.rb +++ b/activerecord/test/cases/relation/merging_test.rb @@ -135,6 +135,18 @@ class RelationMergingTest < ActiveRecord::TestCase relation = Post.all.merge(Post.order(Arel.sql("title LIKE '%?'"))) assert_equal ["title LIKE '%?'"], relation.order_values end + + def test_merging_annotations_respects_merge_order + assert_sql(%r{/\* foo \*/ /\* bar \*/}) do + Post.annotate("foo").merge(Post.annotate("bar")).first + end + assert_sql(%r{/\* bar \*/ /\* foo \*/}) do + Post.annotate("bar").merge(Post.annotate("foo")).first + end + assert_sql(%r{/\* foo \*/ /\* bar \*/ /\* baz \*/ /\* qux \*/}) do + Post.annotate("foo").annotate("bar").merge(Post.annotate("baz").annotate("qux")).first + end + end end class MergingDifferentRelationsTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb index f82ecd4449..96249b8d51 100644 --- a/activerecord/test/cases/relation/mutation_test.rb +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -26,7 +26,7 @@ module ActiveRecord assert relation.order!(:name).equal?(relation) node = relation.order_values.first assert_predicate node, :ascending? - assert_equal :name, node.expr.name + assert_equal "name", node.expr.name assert_equal "posts", node.expr.relation.name end @@ -89,7 +89,7 @@ module ActiveRecord node = relation.order_values.first assert_predicate node, :ascending? - assert_equal :name, node.expr.name + assert_equal "name", node.expr.name assert_equal "posts", node.expr.relation.name end diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb index dec8a6925d..586aaadd0a 100644 --- a/activerecord/test/cases/relation/select_test.rb +++ b/activerecord/test/cases/relation/select_test.rb @@ -11,5 +11,17 @@ module ActiveRecord expected = Post.select(:title).to_sql assert_equal expected, Post.select(nil).select(:title).to_sql end + + def test_reselect + expected = Post.select(:title).to_sql + assert_equal expected, Post.select(:title, :body).reselect(:title).to_sql + end + + def test_reselect_with_default_scope_select + expected = Post.select(:title).to_sql + actual = PostWithDefaultSelect.reselect(:title).to_sql + + assert_equal expected, actual + end end end diff --git a/activerecord/test/cases/relation/update_all_test.rb b/activerecord/test/cases/relation/update_all_test.rb index bb6912148c..e45531b4a9 100644 --- a/activerecord/test/cases/relation/update_all_test.rb +++ b/activerecord/test/cases/relation/update_all_test.rb @@ -138,14 +138,6 @@ class UpdateAllTest < ActiveRecord::TestCase assert_equal new_time, developer.updated_at end - def test_touch_all_updates_locking_column - person = people(:david) - - assert_difference -> { person.reload.lock_version }, +1 do - Person.where(first_name: "David").touch_all - end - end - def test_update_on_relation topic1 = TopicWithCallbacks.create! title: "arel", author_name: nil topic2 = TopicWithCallbacks.create! title: "activerecord", author_name: nil @@ -186,6 +178,101 @@ class UpdateAllTest < ActiveRecord::TestCase end end + def test_update_all_cares_about_optimistic_locking + david = people(:david) + + travel 5.seconds do + now = Time.now.utc + assert_not_equal now, david.updated_at + + people = Person.where(id: people(:michael, :david, :susan)) + expected = people.pluck(:lock_version) + expected.map! { |version| version + 1 } + people.update_all(updated_at: now) + + assert_equal [now] * 3, people.pluck(:updated_at) + assert_equal expected, people.pluck(:lock_version) + + assert_raises(ActiveRecord::StaleObjectError) do + david.touch(time: now) + end + end + end + + def test_update_counters_cares_about_optimistic_locking + david = people(:david) + + travel 5.seconds do + now = Time.now.utc + assert_not_equal now, david.updated_at + + people = Person.where(id: people(:michael, :david, :susan)) + expected = people.pluck(:lock_version) + expected.map! { |version| version + 1 } + people.update_counters(touch: [time: now]) + + assert_equal [now] * 3, people.pluck(:updated_at) + assert_equal expected, people.pluck(:lock_version) + + assert_raises(ActiveRecord::StaleObjectError) do + david.touch(time: now) + end + end + end + + def test_touch_all_cares_about_optimistic_locking + david = people(:david) + + travel 5.seconds do + now = Time.now.utc + assert_not_equal now, david.updated_at + + people = Person.where(id: people(:michael, :david, :susan)) + expected = people.pluck(:lock_version) + expected.map! { |version| version + 1 } + people.touch_all(time: now) + + assert_equal [now] * 3, people.pluck(:updated_at) + assert_equal expected, people.pluck(:lock_version) + + assert_raises(ActiveRecord::StaleObjectError) do + david.touch(time: now) + end + end + end + + def test_klass_level_update_all + travel 5.seconds do + now = Time.now.utc + + Person.all.each do |person| + assert_not_equal now, person.updated_at + end + + Person.update_all(updated_at: now) + + Person.all.each do |person| + assert_equal now, person.updated_at + end + end + end + + def test_klass_level_touch_all + travel 5.seconds do + now = Time.now.utc + + Person.all.each do |person| + assert_not_equal now, person.updated_at + end + + Person.touch_all(time: now) + + Person.all.each do |person| + assert_equal now, person.updated_at + end + end + end + # Oracle UPDATE does not support ORDER BY unless current_adapter?(:OracleAdapter) def test_update_all_ignores_order_without_limit_from_association diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb index 0b06cec40b..b26a1a1d80 100644 --- a/activerecord/test/cases/relation/where_clause_test.rb +++ b/activerecord/test/cases/relation/where_clause_test.rb @@ -106,7 +106,7 @@ class ActiveRecord::Relation Arel::Nodes::Not.new(random_object) ]) - assert_equal expected, original.invert + assert_equal expected, original.invert(:nor) end test "except removes binary predicates referencing a given column" do diff --git a/activerecord/test/cases/relation/where_test.rb b/activerecord/test/cases/relation/where_test.rb index d49ed092b2..6c1e3e7fec 100644 --- a/activerecord/test/cases/relation/where_test.rb +++ b/activerecord/test/cases/relation/where_test.rb @@ -14,11 +14,19 @@ require "models/price_estimate" require "models/topic" require "models/treasure" require "models/vertex" +require "support/stubs/strong_parameters" module ActiveRecord class WhereTest < ActiveRecord::TestCase fixtures :posts, :edges, :authors, :author_addresses, :binaries, :essays, :cars, :treasures, :price_estimates, :topics + def test_in_clause_is_correctly_sliced + assert_called(Author.connection, :in_clause_length, returns: 1) do + david = authors(:david) + assert_equal [david], Author.where(name: "David", id: [1, 2]) + end + end + def test_where_copies_bind_params author = authors(:david) posts = author.posts.where("posts.id != 1") @@ -50,8 +58,13 @@ module ActiveRecord assert_equal [chef], chefs.to_a end - def test_where_with_casted_value_is_nil - assert_equal 4, Topic.where(last_read: "").count + def test_where_with_invalid_value + topics(:first).update!(parent_id: 0, written_on: nil, bonus_time: nil, last_read: nil) + assert_empty Topic.where(parent_id: Object.new) + assert_empty Topic.where(parent_id: "not-a-number") + assert_empty Topic.where(written_on: "") + assert_empty Topic.where(bonus_time: "") + assert_empty Topic.where(last_read: "") end def test_rewhere_on_root @@ -109,13 +122,58 @@ module ActiveRecord assert_equal expected.to_sql, actual.to_sql end - def test_polymorphic_shallow_where_not - treasure = treasures(:sapphire) + def test_where_not_polymorphic_association + sapphire = treasures(:sapphire) - expected = [price_estimates(:diamond), price_estimates(:honda)] - actual = PriceEstimate.where.not(estimate_of: treasure) + all = [treasures(:diamond), sapphire, cars(:honda), sapphire] + assert_equal all, PriceEstimate.all.sort_by(&:id).map(&:estimate_of) - assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + actual = PriceEstimate.where.not(estimate_of: sapphire) + only = PriceEstimate.where(estimate_of: sapphire) + + expected = all - [sapphire] + assert_equal expected, actual.sort_by(&:id).map(&:estimate_of) + assert_equal all - expected, only.sort_by(&:id).map(&:estimate_of) + end + + def test_where_not_polymorphic_id_and_type_as_nand + sapphire = treasures(:sapphire) + + all = [treasures(:diamond), sapphire, cars(:honda), sapphire] + assert_equal all, PriceEstimate.all.sort_by(&:id).map(&:estimate_of) + + actual = PriceEstimate.where.yield_self do |where_chain| + where_chain.stub(:not_behaves_as_nor?, false) do + where_chain.not(estimate_of_type: sapphire.class.polymorphic_name, estimate_of_id: sapphire.id) + end + end + only = PriceEstimate.where(estimate_of_type: sapphire.class.polymorphic_name, estimate_of_id: sapphire.id) + + expected = all - [sapphire] + assert_equal expected, actual.sort_by(&:id).map(&:estimate_of) + assert_equal all - expected, only.sort_by(&:id).map(&:estimate_of) + end + + def test_where_not_polymorphic_id_and_type_as_nor_is_deprecated + sapphire = treasures(:sapphire) + + all = [treasures(:diamond), sapphire, cars(:honda), sapphire] + assert_equal all, PriceEstimate.all.sort_by(&:id).map(&:estimate_of) + + message = <<~MSG.squish + NOT conditions will no longer behave as NOR in Rails 6.1. + To continue using NOR conditions, NOT each conditions manually + (`.where.not(:estimate_of_type => ...).where.not(:estimate_of_id => ...)`). + MSG + actual = assert_deprecated(message) do + PriceEstimate.where.not(estimate_of_type: sapphire.class.polymorphic_name, estimate_of_id: sapphire.id) + end + only = PriceEstimate.where(estimate_of_type: sapphire.class.polymorphic_name, estimate_of_id: sapphire.id) + + expected = all - [sapphire] + # NOT (estimate_of_type = 'Treasure' OR estimate_of_id = sapphire.id) matches only `cars(:honda)` unfortunately. + assert_not_equal expected, actual.sort_by(&:id).map(&:estimate_of) + assert_equal all - expected, only.sort_by(&:id).map(&:estimate_of) end def test_polymorphic_nested_array_where @@ -334,27 +392,8 @@ module ActiveRecord end def test_where_with_strong_parameters - protected_params = Class.new do - attr_reader :permitted - alias :permitted? :permitted - - def initialize(parameters) - @parameters = parameters - @permitted = false - end - - def to_h - @parameters - end - - def permit! - @permitted = true - self - end - end - author = authors(:david) - params = protected_params.new(name: author.name) + params = ProtectedParams.new(name: author.name) assert_raises(ActiveModel::ForbiddenAttributesError) { Author.where(params) } assert_equal author, Author.where(params.permit!).first end diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 68161f6a84..3f370e5ede 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -101,6 +101,9 @@ module ActiveRecord relation.merge!(relation) assert_predicate relation, :empty_scope? + + assert_not_predicate NullPost.all, :empty_scope? + assert_not_predicate FirstPost.all, :empty_scope? end def test_bad_constants_raise_errors @@ -289,6 +292,7 @@ module ActiveRecord klass.create!(description: "foo") assert_equal ["foo"], klass.select(:description).from(klass.all).map(&:desc) + assert_equal ["foo"], klass.reselect(:description).from(klass.all).map(&:desc) end def test_relation_merging_with_merged_joins_as_strings @@ -307,6 +311,58 @@ module ActiveRecord assert_equal 3, ratings.count end + def test_relation_with_annotation_includes_comment_in_to_sql + post_with_annotation = Post.where(id: 1).annotate("foo") + assert_match %r{= 1 /\* foo \*/}, post_with_annotation.to_sql + end + + def test_relation_with_annotation_includes_comment_in_sql + post_with_annotation = Post.where(id: 1).annotate("foo") + assert_sql(%r{/\* foo \*/}) do + assert post_with_annotation.first, "record should be found" + end + end + + def test_relation_with_annotation_chains_sql_comments + post_with_annotation = Post.where(id: 1).annotate("foo").annotate("bar") + assert_sql(%r{/\* foo \*/ /\* bar \*/}) do + assert post_with_annotation.first, "record should be found" + end + end + + def test_relation_with_annotation_filters_sql_comment_delimiters + post_with_annotation = Post.where(id: 1).annotate("**//foo//**") + assert_match %r{= 1 /\* foo \*/}, post_with_annotation.to_sql + end + + def test_relation_with_annotation_includes_comment_in_count_query + post_with_annotation = Post.annotate("foo") + all_count = Post.all.to_a.count + assert_sql(%r{/\* foo \*/}) do + assert_equal all_count, post_with_annotation.count + end + end + + def test_relation_without_annotation_does_not_include_an_empty_comment + log = capture_sql do + Post.where(id: 1).first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + end + + def test_relation_with_optimizer_hints_filters_sql_comment_delimiters + post_with_hint = Post.where(id: 1).optimizer_hints("**//BADHINT//**") + assert_match %r{BADHINT}, post_with_hint.to_sql + assert_no_match %r{\*/BADHINT}, post_with_hint.to_sql + assert_no_match %r{\*//BADHINT}, post_with_hint.to_sql + assert_no_match %r{BADHINT/\*}, post_with_hint.to_sql + assert_no_match %r{BADHINT//\*}, post_with_hint.to_sql + post_with_hint = Post.where(id: 1).optimizer_hints("/*+ BADHINT */") + assert_match %r{/\*\+ BADHINT \*/}, post_with_hint.to_sql + end + class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value def type :string diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 0ab0459c38..2417775ef1 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -14,6 +14,7 @@ require "models/person" require "models/computer" require "models/reply" require "models/company" +require "models/contract" require "models/bird" require "models/car" require "models/engine" @@ -181,15 +182,64 @@ class RelationTest < ActiveRecord::TestCase end end + def test_select_with_from_includes_original_table_name + relation = Comment.joins(:post).select(:id).order(:id) + subquery = Comment.from("#{Comment.table_name} /*! USE INDEX (PRIMARY) */").joins(:post).select(:id).order(:id) + assert_equal relation.map(&:id), subquery.map(&:id) + end + + def test_pluck_with_from_includes_original_table_name + relation = Comment.joins(:post).order(:id) + subquery = Comment.from("#{Comment.table_name} /*! USE INDEX (PRIMARY) */").joins(:post).order(:id) + assert_equal relation.pluck(:id), subquery.pluck(:id) + end + + def test_select_with_from_includes_quoted_original_table_name + relation = Comment.joins(:post).select(:id).order(:id) + subquery = Comment.from("#{Comment.quoted_table_name} /*! USE INDEX (PRIMARY) */").joins(:post).select(:id).order(:id) + assert_equal relation.map(&:id), subquery.map(&:id) + end + + def test_pluck_with_from_includes_quoted_original_table_name + relation = Comment.joins(:post).order(:id) + subquery = Comment.from("#{Comment.quoted_table_name} /*! USE INDEX (PRIMARY) */").joins(:post).order(:id) + assert_equal relation.pluck(:id), subquery.pluck(:id) + end + + def test_select_with_subquery_in_from_uses_original_table_name + relation = Comment.joins(:post).select(:id).order(:id) + # Avoid subquery flattening by adding distinct to work with SQLite < 3.20.0. + subquery = Comment.from(Comment.all.distinct, Comment.quoted_table_name).joins(:post).select(:id).order(:id) + assert_equal relation.map(&:id), subquery.map(&:id) + end + + def test_pluck_with_subquery_in_from_uses_original_table_name + relation = Comment.joins(:post).order(:id) + subquery = Comment.from(Comment.all, Comment.quoted_table_name).joins(:post).order(:id) + assert_equal relation.pluck(:id), subquery.pluck(:id) + end + def test_select_with_subquery_in_from_does_not_use_original_table_name relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") - subquery = Comment.from(relation).select("type", "post_count") + subquery = Comment.from(relation, "grouped_#{Comment.table_name}").select("type", "post_count") assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) end def test_group_with_subquery_in_from_does_not_use_original_table_name relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") - subquery = Comment.from(relation).group("type").average("post_count") + subquery = Comment.from(relation, "grouped_#{Comment.table_name}").group("type").average("post_count") + assert_equal(relation.map(&:post_count).sort, subquery.values.sort) + end + + def test_select_with_subquery_string_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") + subquery = Comment.from("(#{relation.to_sql}) #{Comment.table_name}_grouped").select("type", "post_count") + assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) + end + + def test_group_with_subquery_string_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") + subquery = Comment.from("(#{relation.to_sql}) #{Comment.table_name}_grouped").group("type").average("post_count") assert_equal(relation.map(&:post_count).sort, subquery.values.sort) end @@ -290,8 +340,17 @@ class RelationTest < ActiveRecord::TestCase Topic.order(Arel.sql("title NULLS FIRST")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("title NULLS FIRST")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do Topic.order(Arel.sql("title nulls last")).reverse_order end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("title NULLS FIRST, author_name")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("author_name, title nulls last")).reverse_order + end end def test_default_reverse_order_on_table_without_primary_key @@ -543,6 +602,13 @@ class RelationTest < ActiveRecord::TestCase end end + def test_extracted_association + relation_authors = assert_queries(2) { Post.all.extract_associated(:author) } + root_authors = assert_queries(2) { Post.extract_associated(:author) } + assert_equal relation_authors, root_authors + assert_equal Post.all.collect(&:author), relation_authors + end + def test_find_with_included_associations assert_queries(2) do posts = Post.includes(:comments).order("posts.id") @@ -919,12 +985,25 @@ class RelationTest < ActiveRecord::TestCase assert_queries(1) { assert_equal 11, posts.load.size } end + def test_size_with_eager_loading_and_custom_select_and_order + posts = Post.includes(:comments).order("comments.id").select(:type) + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + end + def test_size_with_eager_loading_and_custom_order_and_distinct posts = Post.includes(:comments).order("comments.id").distinct assert_queries(1) { assert_equal 11, posts.size } assert_queries(1) { assert_equal 11, posts.load.size } end + def test_size_with_eager_loading_and_manual_distinct_select_and_custom_order + accounts = Account.select("DISTINCT accounts.firm_id").order("accounts.firm_id") + + assert_queries(1) { assert_equal 5, accounts.size } + assert_queries(1) { assert_equal 5, accounts.load.size } + end + def test_count_explicit_columns Post.update_all(comments_count: nil) posts = Post.all @@ -1166,13 +1245,23 @@ class RelationTest < ActiveRecord::TestCase assert_equal "green", parrot.color end + def test_first_or_create_with_after_initialize + Bird.create!(color: "yellow", name: "canary") + parrot = assert_deprecated do + Bird.where(color: "green").first_or_create do |bird| + bird.name = "parrot" + bird.enable_count = true + end + end + assert_equal 0, parrot.total_count + end + def test_first_or_create_with_block - canary = Bird.create!(color: "yellow", name: "canary") + Bird.create!(color: "yellow", name: "canary") parrot = Bird.where(color: "green").first_or_create do |bird| bird.name = "parrot" - assert_equal canary, Bird.find_by!(name: "canary") + assert_deprecated { assert_equal 0, Bird.count } end - assert_equal 1, parrot.total_count assert_kind_of Bird, parrot assert_predicate parrot, :persisted? assert_equal "green", parrot.color @@ -1213,13 +1302,23 @@ class RelationTest < ActiveRecord::TestCase assert_raises(ActiveRecord::RecordInvalid) { Bird.where(color: "green").first_or_create! } end + def test_first_or_create_bang_with_after_initialize + Bird.create!(color: "yellow", name: "canary") + parrot = assert_deprecated do + Bird.where(color: "green").first_or_create! do |bird| + bird.name = "parrot" + bird.enable_count = true + end + end + assert_equal 0, parrot.total_count + end + def test_first_or_create_bang_with_valid_block - canary = Bird.create!(color: "yellow", name: "canary") + Bird.create!(color: "yellow", name: "canary") parrot = Bird.where(color: "green").first_or_create! do |bird| bird.name = "parrot" - assert_equal canary, Bird.find_by!(name: "canary") + assert_deprecated { assert_equal 0, Bird.count } end - assert_equal 1, parrot.total_count assert_kind_of Bird, parrot assert_predicate parrot, :persisted? assert_equal "green", parrot.color @@ -1268,13 +1367,23 @@ class RelationTest < ActiveRecord::TestCase assert_equal "green", parrot.color end + def test_first_or_initialize_with_after_initialize + Bird.create!(color: "yellow", name: "canary") + parrot = assert_deprecated do + Bird.where(color: "green").first_or_initialize do |bird| + bird.name = "parrot" + bird.enable_count = true + end + end + assert_equal 0, parrot.total_count + end + def test_first_or_initialize_with_block - canary = Bird.create!(color: "yellow", name: "canary") + Bird.create!(color: "yellow", name: "canary") parrot = Bird.where(color: "green").first_or_initialize do |bird| bird.name = "parrot" - assert_equal canary, Bird.find_by!(name: "canary") + assert_deprecated { assert_equal 0, Bird.count } end - assert_equal 1, parrot.total_count assert_kind_of Bird, parrot assert_not_predicate parrot, :persisted? assert_predicate parrot, :valid? @@ -1406,10 +1515,12 @@ class RelationTest < ActiveRecord::TestCase assert_equal [posts(:welcome)], relation.to_a author_posts = relation.except(:order, :limit) - assert_equal Post.where(author_id: 1).to_a, author_posts.to_a + assert_equal Post.where(author_id: 1).sort_by(&:id), author_posts.sort_by(&:id) + assert_equal author_posts.sort_by(&:id), relation.scoping { Post.except(:order, :limit).sort_by(&:id) } all_posts = relation.except(:where, :order, :limit) - assert_equal Post.all, all_posts + assert_equal Post.all.sort_by(&:id), all_posts.sort_by(&:id) + assert_equal all_posts.sort_by(&:id), relation.scoping { Post.except(:where, :order, :limit).sort_by(&:id) } end def test_only @@ -1417,10 +1528,12 @@ class RelationTest < ActiveRecord::TestCase assert_equal [posts(:welcome)], relation.to_a author_posts = relation.only(:where) - assert_equal Post.where(author_id: 1).to_a, author_posts.to_a + assert_equal Post.where(author_id: 1).sort_by(&:id), author_posts.sort_by(&:id) + assert_equal author_posts.sort_by(&:id), relation.scoping { Post.only(:where).sort_by(&:id) } - all_posts = relation.only(:limit) - assert_equal Post.limit(1).to_a, all_posts.to_a + all_posts = relation.only(:order) + assert_equal Post.order("id ASC").to_a, all_posts.to_a + assert_equal all_posts.to_a, relation.scoping { Post.only(:order).to_a } end def test_anonymous_extension @@ -1646,6 +1759,24 @@ class RelationTest < ActiveRecord::TestCase assert_predicate topics, :loaded? end + def test_delete_by + david = authors(:david) + + assert_difference("Post.count", -3) { david.posts.delete_by(body: "hello") } + + deleted = Author.delete_by(id: david.id) + assert_equal 1, deleted + end + + def test_destroy_by + david = authors(:david) + + assert_difference("Post.count", -3) { david.posts.destroy_by(body: "hello") } + + destroyed = Author.destroy_by(id: david.id) + assert_equal [david], destroyed + end + test "find_by with hash conditions returns the first matching record" do assert_equal posts(:eager_other), Post.order(:id).find_by(author_id: 2) end @@ -1815,6 +1946,19 @@ class RelationTest < ActiveRecord::TestCase assert_equal [1, 1, 1], posts.map(&:author_address_id) end + test "joins with select custom attribute" do + contract = Company.create!(name: "test").contracts.create! + company = Company.joins(:contracts).select(:id, :metadata).find(contract.company_id) + assert_equal contract.metadata, company.metadata + end + + test "joins with order by custom attribute" do + companies = Company.create!([{ name: "test1" }, { name: "test2" }]) + companies.each { |company| company.contracts.create! } + assert_equal companies, Company.joins(:contracts).order(:metadata) + assert_equal companies.reverse, Company.joins(:contracts).order(metadata: :desc) + end + test "delegations do not leak to other classes" do Topic.all.by_lifo assert Topic.all.class.method_defined?(:by_lifo) @@ -1833,6 +1977,30 @@ class RelationTest < ActiveRecord::TestCase assert_equal p2.first.comments, comments end + def test_unscope_with_merge + p0 = Post.where(author_id: 0) + p1 = Post.where(author_id: 1, comments_count: 1) + + assert_equal [posts(:authorless)], p0 + assert_equal [posts(:thinking)], p1 + + comments = Comment.merge(p0).unscope(where: :author_id).where(post: p1) + + assert_not_equal p0.first.comments, comments + assert_equal p1.first.comments, comments + end + + def test_unscope_with_unknown_column + comment = comments(:greetings) + comment.update!(comments: 1) + + comments = Comment.where(comments: 1).unscope(where: :unknown_column) + assert_equal [comment], comments + + comments = Comment.where(comments: 1).unscope(where: { comments: :unknown_column }) + assert_equal [comment], comments + end + def test_unscope_specific_where_value posts = Post.where(title: "Welcome to the weblog", body: "Such a lovely day") diff --git a/activerecord/test/cases/sanitize_test.rb b/activerecord/test/cases/sanitize_test.rb index 18b27bd6d1..6c884b4f45 100644 --- a/activerecord/test/cases/sanitize_test.rb +++ b/activerecord/test/cases/sanitize_test.rb @@ -148,6 +148,19 @@ class SanitizeTest < ActiveRecord::TestCase assert_equal "foo in (#{quoted_nil})", bind("foo in (?)", []) end + def test_bind_range + quoted_abc = %(#{ActiveRecord::Base.connection.quote('a')},#{ActiveRecord::Base.connection.quote('b')},#{ActiveRecord::Base.connection.quote('c')}) + assert_equal "0", bind("?", 0..0) + assert_equal "1,2,3", bind("?", 1..3) + assert_equal quoted_abc, bind("?", "a"..."d") + end + + def test_bind_empty_range + quoted_nil = ActiveRecord::Base.connection.quote(nil) + assert_equal quoted_nil, bind("?", 0...0) + assert_equal quoted_nil, bind("?", "a"..."a") + end + def test_bind_empty_string quoted_empty = ActiveRecord::Base.connection.quote("") assert_equal quoted_empty, bind("?", "") diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb index 6281712df6..e7bdab58c6 100644 --- a/activerecord/test/cases/scoping/default_scoping_test.rb +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -408,18 +408,18 @@ class DefaultScopingTest < ActiveRecord::TestCase end def test_joins_not_affected_by_scope_other_than_default_or_unscoped - without_scope_on_post = Comment.joins(:post).to_a + without_scope_on_post = Comment.joins(:post).sort_by(&:id) with_scope_on_post = nil Post.where(id: [1, 5, 6]).scoping do - with_scope_on_post = Comment.joins(:post).to_a + with_scope_on_post = Comment.joins(:post).sort_by(&:id) end - assert_equal with_scope_on_post, without_scope_on_post + assert_equal without_scope_on_post, with_scope_on_post end def test_unscoped_with_joins_should_not_have_default_scope - assert_equal SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).to_a }, - Comment.joins(:post).to_a + assert_equal Comment.joins(:post).sort_by(&:id), + SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).sort_by(&:id) } end def test_sti_association_with_unscoped_not_affected_by_default_scope diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb index 27f9df295f..3488442cab 100644 --- a/activerecord/test/cases/scoping/named_scoping_test.rb +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -50,7 +50,7 @@ class NamedScopingTest < ActiveRecord::TestCase def test_calling_merge_at_first_in_scope Topic.class_eval do - scope :calling_merge_at_first_in_scope, Proc.new { merge(Topic.replied) } + scope :calling_merge_at_first_in_scope, Proc.new { merge(Topic.unscoped.replied) } end assert_equal Topic.calling_merge_at_first_in_scope.to_a, Topic.replied.to_a end @@ -447,9 +447,10 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal [posts(:sti_comments)], Post.with_special_comments.with_post(4).to_a.uniq end - def test_chaining_doesnt_leak_conditions_to_another_scopes - expected = Topic.where(approved: false).where(id: Topic.children.select(:parent_id)) - assert_equal expected.to_a, Topic.rejected.has_children.to_a + def test_class_method_in_scope + assert_deprecated do + assert_equal [topics(:second)], topics(:first).approved_replies.ordered + end end def test_nested_scoping @@ -601,4 +602,14 @@ class NamedScopingTest < ActiveRecord::TestCase Topic.create! assert_predicate Topic, :one? end + + def test_scope_with_annotation + Topic.class_eval do + scope :including_annotate_in_scope, Proc.new { annotate("from-scope") } + end + + assert_sql(%r{/\* from-scope \*/}) do + assert Topic.including_annotate_in_scope.to_a, Topic.all.to_a + end + end end diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb index b1f2ffe29c..50b514d464 100644 --- a/activerecord/test/cases/scoping/relation_scoping_test.rb +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -130,6 +130,44 @@ class RelationScopingTest < ActiveRecord::TestCase end end + def test_scoped_find_with_annotation + Developer.annotate("scoped").scoping do + developer = nil + assert_sql(%r{/\* scoped \*/}) do + developer = Developer.where("name = 'David'").first + end + assert_equal "David", developer.name + end + end + + def test_find_with_annotation_unscoped + Developer.annotate("scoped").unscoped do + developer = nil + log = capture_sql do + developer = Developer.where("name = 'David'").first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\* scoped \*/}) }, :empty? + + assert_equal "David", developer.name + end + end + + def test_find_with_annotation_unscope + developer = nil + log = capture_sql do + developer = Developer.annotate("unscope"). + where("name = 'David'"). + unscope(:annotate).first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\* unscope \*/}) }, :empty? + + assert_equal "David", developer.name + end + def test_scoped_find_include # with the include, will retrieve only developers for the given project scoped_developers = Developer.includes(:projects).scoping do @@ -373,7 +411,19 @@ class HasManyScopingTest < ActiveRecord::TestCase def test_nested_scope_finder Comment.where("1=0").scoping do - assert_equal 0, @welcome.comments.count + assert_equal 2, @welcome.comments.count + assert_equal "a comment...", @welcome.comments.what_are_you + end + + Comment.where("1=1").scoping do + assert_equal 2, @welcome.comments.count + assert_equal "a comment...", @welcome.comments.what_are_you + end + end + + def test_none_scoping + Comment.none.scoping do + assert_equal 2, @welcome.comments.count assert_equal "a comment...", @welcome.comments.what_are_you end @@ -414,7 +464,19 @@ class HasAndBelongsToManyScopingTest < ActiveRecord::TestCase def test_nested_scope_finder Category.where("1=0").scoping do - assert_equal 0, @welcome.categories.count + assert_equal 2, @welcome.categories.count + assert_equal "a category...", @welcome.categories.what_are_you + end + + Category.where("1=1").scoping do + assert_equal 2, @welcome.categories.count + assert_equal "a category...", @welcome.categories.what_are_you + end + end + + def test_none_scoping + Category.none.scoping do + assert_equal 2, @welcome.categories.count assert_equal "a category...", @welcome.categories.what_are_you end diff --git a/activerecord/test/cases/serialized_attribute_test.rb b/activerecord/test/cases/serialized_attribute_test.rb index fa136fe8da..ecf81b2042 100644 --- a/activerecord/test/cases/serialized_attribute_test.rb +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -1,28 +1,29 @@ # frozen_string_literal: true require "cases/helper" -require "models/topic" -require "models/reply" require "models/person" require "models/traffic_light" require "models/post" -require "bcrypt" class SerializedAttributeTest < ActiveRecord::TestCase fixtures :topics, :posts MyObject = Struct.new :attribute1, :attribute2 - # NOTE: Use a duplicate of Topic so attribute - # changes don't bleed into other tests - Topic = ::Topic.dup + class Topic < ActiveRecord::Base + serialize :content + end + + class ImportantTopic < Topic + serialize :important, Hash + end teardown do Topic.serialize("content") end def test_serialize_does_not_eagerly_load_columns - reset_column_information_of(Topic) + Topic.reset_column_information assert_no_queries do Topic.serialize(:content) end @@ -53,10 +54,10 @@ class SerializedAttributeTest < ActiveRecord::TestCase def test_serialized_attributes_from_database_on_subclass Topic.serialize :content, Hash - t = Reply.new(content: { foo: :bar }) + t = ImportantTopic.new(content: { foo: :bar }) assert_equal({ foo: :bar }, t.content) t.save! - t = Reply.last + t = ImportantTopic.last assert_equal({ foo: :bar }, t.content) end @@ -371,14 +372,13 @@ class SerializedAttributeTest < ActiveRecord::TestCase end def test_serialized_attribute_works_under_concurrent_initial_access - model = ::Topic.dup + model = Class.new(Topic) - topic = model.last + topic = model.create! topic.update group: "1" model.serialize :group, JSON - - reset_column_information_of(model) + model.reset_column_information # This isn't strictly necessary for the test, but a little bit of # knowledge of internals allows us to make failures far more likely. @@ -398,12 +398,4 @@ class SerializedAttributeTest < ActiveRecord::TestCase # raw string ("1"), or raise an exception. assert_equal [1] * threads.size, threads.map(&:value) end - - private - - def reset_column_information_of(topic_class) - topic_class.reset_column_information - # reset original topic to undefine attribute methods - ::Topic.reset_column_information - end end diff --git a/activerecord/test/cases/store_test.rb b/activerecord/test/cases/store_test.rb index 4457cfbd37..91c0e959f4 100644 --- a/activerecord/test/cases/store_test.rb +++ b/activerecord/test/cases/store_test.rb @@ -79,6 +79,74 @@ class StoreTest < ActiveRecord::TestCase assert_not_predicate @john, :settings_changed? end + test "updating the store will mark accessor as changed" do + @john.color = "red" + assert @john.color_changed? + end + + test "new record and no accessors changes" do + user = Admin::User.new + assert_not user.color_changed? + assert_nil user.color_was + assert_nil user.color_change + + user.color = "red" + assert user.color_changed? + assert_nil user.color_was + assert_equal "red", user.color_change[1] + end + + test "updating the store won't mark accessor as changed if the whole store was updated" do + @john.settings = { color: @john.color, some: "thing" } + assert @john.settings_changed? + assert_not @john.color_changed? + end + + test "updating the store populates the accessor changed array correctly" do + @john.color = "red" + assert_equal "black", @john.color_was + assert_equal "black", @john.color_change[0] + assert_equal "red", @john.color_change[1] + end + + test "updating the store won't mark accessor as changed if the value isn't changed" do + @john.color = @john.color + assert_not @john.color_changed? + end + + test "nullifying the store mark accessor as changed" do + color = @john.color + @john.settings = nil + assert @john.color_changed? + assert_equal color, @john.color_was + assert_equal [color, nil], @john.color_change + end + + test "dirty methods for suffixed accessors" do + @john.configs[:two_factor_auth] = true + assert @john.two_factor_auth_configs_changed? + assert_nil @john.two_factor_auth_configs_was + assert_equal [nil, true], @john.two_factor_auth_configs_change + end + + test "dirty methods for prefixed accessors" do + @john.spouse[:name] = "Lena" + assert @john.partner_name_changed? + assert_equal "Dallas", @john.partner_name_was + assert_equal ["Dallas", "Lena"], @john.partner_name_change + end + + test "saved changes tracking for accessors" do + @john.spouse[:name] = "Lena" + assert @john.partner_name_changed? + + @john.save! + assert_not @john.partner_name_change + assert @john.saved_change_to_partner_name? + assert_equal ["Dallas", "Lena"], @john.saved_change_to_partner_name + assert_equal "Dallas", @john.partner_name_before_last_save + end + test "object initialization with not nullable column" do assert_equal true, @john.remember_login end diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 3fd1813d64..ffe94eee0f 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "active_record/tasks/database_tasks" +require "models/author" module ActiveRecord module DatabaseTasksSetupper @@ -49,6 +50,8 @@ module ActiveRecord protected_environments = ActiveRecord::Base.protected_environments current_env = ActiveRecord::Base.connection.migration_context.current_environment + InternalMetadata[:environment] = current_env + assert_called_on_instance_of( ActiveRecord::MigrationContext, :current_version, @@ -72,6 +75,9 @@ module ActiveRecord def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_symbol protected_environments = ActiveRecord::Base.protected_environments current_env = ActiveRecord::Base.connection.migration_context.current_environment + + InternalMetadata[:environment] = current_env + assert_called_on_instance_of( ActiveRecord::MigrationContext, :current_version, @@ -754,7 +760,7 @@ module ActiveRecord end class DatabaseTasksMigrateTest < DatabaseTasksMigrationTestCase - def test_migrate_set_and_unset_verbose_and_version_env_vars + def test_can_migrate_from_pending_migration_error_action_dispatch verbose, version = ENV["VERBOSE"], ENV["VERSION"] ENV["VERSION"] = "2" ENV["VERBOSE"] = "false" @@ -766,7 +772,9 @@ module ActiveRecord ENV.delete("VERBOSE") # re-run up migration - assert_includes capture_migration_output, "migrating" + assert_includes(capture(:stdout) do + ActiveSupport::ActionableError.dispatch ActiveRecord::PendingMigrationError, "Run pending migrations" + end, "migrating") ensure ENV["VERBOSE"], ENV["VERSION"] = verbose, version end @@ -944,6 +952,176 @@ module ActiveRecord end end + unless in_memory_db? + class DatabaseTasksTruncateAllTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :authors, :author_addresses + + def setup + SchemaMigration.create_table + SchemaMigration.create!(version: SchemaMigration.table_name) + InternalMetadata.create_table + InternalMetadata.create!(key: InternalMetadata.table_name) + end + + def teardown + SchemaMigration.delete_all + InternalMetadata.delete_all + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + + def test_truncate_tables + assert_operator SchemaMigration.count, :>, 0 + assert_operator InternalMetadata.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 + + old_configurations = ActiveRecord::Base.configurations + configurations = { development: ActiveRecord::Base.configurations["arunit"] } + ActiveRecord::Base.configurations = configurations + + ActiveRecord::Tasks::DatabaseTasks.stub(:root, nil) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("development") + ) + end + + assert_operator SchemaMigration.count, :>, 0 + assert_operator InternalMetadata.count, :>, 0 + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksTruncateAllWithPrefixTest < DatabaseTasksTruncateAllTest + setup do + ActiveRecord::Base.table_name_prefix = "p_" + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + + teardown do + ActiveRecord::Base.table_name_prefix = nil + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + end + + class DatabaseTasksTruncateAllWithSuffixTest < DatabaseTasksTruncateAllTest + setup do + ActiveRecord::Base.table_name_suffix = "_s" + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + + teardown do + ActiveRecord::Base.table_name_suffix = nil + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + end + end + + class DatabaseTasksTruncateAllWithMultipleDatabasesTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, + "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } + } + end + + def test_truncate_all_databases_for_environment + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_truncate_all_databases_with_url_for_environment + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_truncate_all_development_databases_when_env_is_not_specified + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_truncate_all_development_databases_when_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + class DatabaseTasksCharsetTest < ActiveRecord::TestCase include DatabaseTasksSetupper diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb index 5b25432dc0..81f7226718 100644 --- a/activerecord/test/cases/test_case.rb +++ b/activerecord/test/cases/test_case.rb @@ -107,32 +107,12 @@ module ActiveRecord clear_log - self.ignored_sql = [/^PRAGMA/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/, /^SHOW max_identifier_length/, /^BEGIN/, /^COMMIT/] - - # FIXME: this needs to be refactored so specific database can add their own - # ignored SQL, or better yet, use a different notification for the queries - # instead examining the SQL content. - oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im, /^\s*select .* from all_sequences/im] - mysql_ignored = [/^SHOW FULL TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /, /^\s*SELECT (?:column_name|table_name)\b.*\bFROM information_schema\.(?:key_column_usage|tables)\b/im] - postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i, /^\s*SELECT\b.*::regtype::oid\b/im] - sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im, /^\s*SELECT sql\b.*\bFROM sqlite_master/im] - - [oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql| - ignored_sql.concat db_ignored_sql - end - - attr_reader :ignore - - def initialize(ignore = Regexp.union(self.class.ignored_sql)) - @ignore = ignore - end - def call(name, start, finish, message_id, values) return if values[:cached] sql = values[:sql] self.class.log_all << sql - self.class.log << sql unless ignore.match?(sql) + self.class.log << sql unless ["SCHEMA", "TRANSACTION"].include? values[:name] end end diff --git a/activerecord/test/cases/time_precision_test.rb b/activerecord/test/cases/time_precision_test.rb index 086500de38..1abd857216 100644 --- a/activerecord/test/cases/time_precision_test.rb +++ b/activerecord/test/cases/time_precision_test.rb @@ -45,6 +45,26 @@ if subsecond_precision_supported? assert_equal 123456000, foo.finish.nsec end + unless current_adapter?(:Mysql2Adapter) + def test_no_time_precision_isnt_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :start, :time + @connection.add_column :foos, :finish, :time, precision: 6 + + time = ::Time.now.change(nsec: 123) + foo = Foo.new(start: time, finish: time) + + assert_equal 123, foo.start.nsec + assert_equal 0, foo.finish.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.start.nsec + assert_equal 0, foo.finish.nsec + end + end + def test_passing_precision_to_time_does_not_set_limit @connection.create_table(:foos, force: true) do |t| t.time :start, precision: 3 @@ -55,7 +75,7 @@ if subsecond_precision_supported? end def test_invalid_time_precision_raises_error - assert_raises ActiveRecord::ActiveRecordError do + assert_raises ArgumentError do @connection.create_table(:foos, force: true) do |t| t.time :start, precision: 7 t.time :finish, precision: 7 diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb index 75ecd6fc40..232e018e03 100644 --- a/activerecord/test/cases/timestamp_test.rb +++ b/activerecord/test/cases/timestamp_test.rb @@ -40,17 +40,25 @@ class TimestampTest < ActiveRecord::TestCase assert_not_equal @previously_updated_at, @developer.updated_at assert_equal previous_salary + 10000, @developer.salary - assert @developer.salary_changed?, "developer salary should have changed" - assert @developer.changed?, "developer should be marked as changed" + assert_predicate @developer, :salary_changed?, "developer salary should have changed" + assert_predicate @developer, :changed?, "developer should be marked as changed" + assert_equal ["salary"], @developer.changed + assert_predicate @developer, :saved_changes? + assert_equal ["updated_at", "updated_on"], @developer.saved_changes.keys.sort + @developer.reload assert_equal previous_salary, @developer.salary end def test_touching_a_record_with_default_scope_that_excludes_it_updates_its_timestamp developer = @developer.becomes(DeveloperCalledJamis) - developer.touch + assert_not_equal @previously_updated_at, developer.updated_at + assert_not_predicate developer, :changed? + assert_predicate developer, :saved_changes? + assert_equal ["updated_at", "updated_on"], developer.saved_changes.keys.sort + developer.reload assert_not_equal @previously_updated_at, developer.updated_at end diff --git a/activerecord/test/cases/touch_later_test.rb b/activerecord/test/cases/touch_later_test.rb index cd3d5ed7d1..f1a9cf2d05 100644 --- a/activerecord/test/cases/touch_later_test.rb +++ b/activerecord/test/cases/touch_later_test.rb @@ -10,7 +10,7 @@ require "models/tree" class TouchLaterTest < ActiveRecord::TestCase fixtures :nodes, :trees - def test_touch_laster_raise_if_non_persisted + def test_touch_later_raise_if_non_persisted invoice = Invoice.new Invoice.transaction do assert_not_predicate invoice, :persisted? diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb index aa6b7915a2..100bd6a925 100644 --- a/activerecord/test/cases/transaction_callbacks_test.rb +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -36,8 +36,11 @@ class TransactionCallbacksTest < ActiveRecord::TestCase has_many :replies, class_name: "ReplyWithCallbacks", foreign_key: "parent_id" + before_destroy { self.class.find(id).touch if persisted? } + before_commit { |record| record.do_before_commit(nil) } after_commit { |record| record.do_after_commit(nil) } + after_save_commit { |record| record.do_after_commit(:save) } after_create_commit { |record| record.do_after_commit(:create) } after_update_commit { |record| record.do_after_commit(:update) } after_destroy_commit { |record| record.do_after_commit(:destroy) } @@ -110,6 +113,43 @@ class TransactionCallbacksTest < ActiveRecord::TestCase assert_equal [:after_commit], @first.history end + def test_dont_call_any_callbacks_after_transaction_commits_for_invalid_record + @first.after_commit_block { |r| r.history << :after_commit } + @first.after_rollback_block { |r| r.history << :after_rollback } + + def @first.valid?(*) + false + end + + assert_not @first.save + assert_equal [], @first.history + end + + def test_dont_call_any_callbacks_after_explicit_transaction_commits_for_invalid_record + @first.after_commit_block { |r| r.history << :after_commit } + @first.after_rollback_block { |r| r.history << :after_rollback } + + def @first.valid?(*) + false + end + + @first.transaction do + assert_not @first.save + end + assert_equal [], @first.history + end + + def test_only_call_after_commit_on_save_after_transaction_commits_for_saving_record + record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) + record.after_commit_block(:save) { |r| r.history << :after_save } + + record.save! + assert_equal [:after_save], record.history + + record.update!(title: "Another topic") + assert_equal [:after_save, :after_save], record.history + end + def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record add_transaction_execution_blocks @first @@ -511,6 +551,8 @@ class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase end class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase + self.use_transactional_tests = false + class TopicWithHistory < ActiveRecord::Base self.table_name = :topics @@ -524,11 +566,22 @@ class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase end class TopicWithCallbacksOnDestroy < TopicWithHistory - after_commit(on: :destroy) { |record| record.class.history << :destroy } + after_commit(on: :destroy) { |record| record.class.history << :commit_on_destroy } + after_rollback(on: :destroy) { |record| record.class.history << :rollback_on_destroy } + + before_destroy :before_destroy_for_transaction + + private + def before_destroy_for_transaction; end end class TopicWithCallbacksOnUpdate < TopicWithHistory - after_commit(on: :update) { |record| record.class.history << :update } + after_commit(on: :update) { |record| record.class.history << :commit_on_update } + + before_save :before_save_for_transaction + + private + def before_save_for_transaction; end end def test_trigger_once_on_multiple_deletions @@ -536,10 +589,39 @@ class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase topic = TopicWithCallbacksOnDestroy.new topic.save topic_clone = TopicWithCallbacksOnDestroy.find(topic.id) + + topic.define_singleton_method(:before_destroy_for_transaction) do + topic_clone.destroy + end + topic.destroy - topic_clone.destroy - assert_equal [:destroy], TopicWithCallbacksOnDestroy.history + assert_equal [:commit_on_destroy], TopicWithCallbacksOnDestroy.history + end + + def test_rollback_on_multiple_deletions + TopicWithCallbacksOnDestroy.clear_history + topic = TopicWithCallbacksOnDestroy.new + topic.save + topic_clone = TopicWithCallbacksOnDestroy.find(topic.id) + + topic.define_singleton_method(:before_destroy_for_transaction) do + topic_clone.update!(author_name: "Test Author Clone") + topic_clone.destroy + end + + TopicWithCallbacksOnDestroy.transaction do + topic.update!(author_name: "Test Author") + topic.destroy + raise ActiveRecord::Rollback + end + + assert_not_predicate topic, :destroyed? + assert_not_predicate topic_clone, :destroyed? + assert_equal [nil, "Test Author"], topic.author_name_change_to_be_saved + assert_equal [nil, "Test Author Clone"], topic_clone.author_name_change_to_be_saved + + assert_equal [:rollback_on_destroy], TopicWithCallbacksOnDestroy.history end def test_trigger_on_update_where_row_was_deleted @@ -547,7 +629,11 @@ class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase topic = TopicWithCallbacksOnUpdate.new topic.save topic_clone = TopicWithCallbacksOnUpdate.find(topic.id) - topic.destroy + + topic_clone.define_singleton_method(:before_save_for_transaction) do + topic.destroy + end + topic_clone.author_name = "Test Author" topic_clone.save @@ -586,7 +672,7 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase @topic.content = "foo" @topic.save! end - @topic.class.connection.add_transaction_record(@topic) + @topic.send(:add_to_transaction) end assert_equal [:before_commit, :after_commit], @topic.history end @@ -596,7 +682,7 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase @topic.transaction(requires_new: true) do @topic.content = "foo" @topic.save! - @topic.class.connection.add_transaction_record(@topic) + @topic.send(:add_to_transaction) end end assert_equal [:before_commit, :after_commit], @topic.history @@ -617,7 +703,7 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase @topic.content = "foo" @topic.save! end - @topic.class.connection.add_transaction_record(@topic) + @topic.send(:add_to_transaction) raise ActiveRecord::Rollback end assert_equal [:rollback], @topic.history diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb index 1009dd0f99..6795996cca 100644 --- a/activerecord/test/cases/transactions_test.rb +++ b/activerecord/test/cases/transactions_test.rb @@ -18,6 +18,65 @@ class TransactionTest < ActiveRecord::TestCase @first, @second = Topic.find(1, 2).sort_by(&:id) end + def test_rollback_dirty_changes + topic = topics(:fifth) + + ActiveRecord::Base.transaction do + topic.update(title: "Ruby on Rails") + raise ActiveRecord::Rollback + end + + title_change = ["The Fifth Topic of the day", "Ruby on Rails"] + assert_equal title_change, topic.changes["title"] + end + + def test_rollback_dirty_changes_multiple_saves + topic = topics(:fifth) + + ActiveRecord::Base.transaction do + topic.update(title: "Ruby on Rails") + topic.update(title: "Another Title") + raise ActiveRecord::Rollback + end + + title_change = ["The Fifth Topic of the day", "Another Title"] + assert_equal title_change, topic.changes["title"] + end + + def test_rollback_dirty_changes_then_retry_save + topic = topics(:fifth) + + ActiveRecord::Base.transaction do + topic.update(title: "Ruby on Rails") + raise ActiveRecord::Rollback + end + + title_change = ["The Fifth Topic of the day", "Ruby on Rails"] + assert_equal title_change, topic.changes["title"] + + assert topic.save + + assert_equal title_change, topic.saved_changes["title"] + assert_equal topic.title, topic.reload.title + end + + def test_rollback_dirty_changes_then_retry_save_on_new_record + topic = Topic.new(title: "Ruby on Rails") + + ActiveRecord::Base.transaction do + topic.save + raise ActiveRecord::Rollback + end + + title_change = [nil, "Ruby on Rails"] + assert_equal title_change, topic.changes["title"] + + assert topic.save + + assert_equal title_change, topic.saved_changes["title"] + assert_equal topic.title, topic.reload.title + end + def test_persisted_in_a_model_with_custom_primary_key_after_failed_save movie = Movie.create assert_not_predicate movie, :persisted? @@ -26,28 +85,31 @@ class TransactionTest < ActiveRecord::TestCase def test_raise_after_destroy assert_not_predicate @first, :frozen? - assert_raises(RuntimeError) { - Topic.transaction do - @first.destroy - assert_predicate @first, :frozen? - raise + assert_not_called(@first, :rolledback!) do + assert_raises(RuntimeError) do + Topic.transaction do + @first.destroy + assert_predicate @first, :frozen? + raise + end end - } + end - assert @first.reload assert_not_predicate @first, :frozen? end def test_successful - Topic.transaction do - @first.approved = true - @second.approved = false - @first.save - @second.save + assert_not_called(@first, :committed!) do + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end end - assert Topic.find(1).approved?, "First should have been approved" - assert_not Topic.find(2).approved?, "Second should have been unapproved" + assert_predicate Topic.find(1), :approved?, "First should have been approved" + assert_not_predicate Topic.find(2), :approved?, "Second should have been unapproved" end def transaction_with_return @@ -62,7 +124,7 @@ class TransactionTest < ActiveRecord::TestCase def test_add_to_null_transaction topic = Topic.new - topic.add_to_transaction + topic.send(:add_to_transaction) end def test_successful_with_return @@ -76,11 +138,13 @@ class TransactionTest < ActiveRecord::TestCase end end - transaction_with_return + assert_not_called(@first, :committed!) do + transaction_with_return + end assert committed - assert Topic.find(1).approved?, "First should have been approved" - assert_not Topic.find(2).approved?, "Second should have been unapproved" + assert_predicate Topic.find(1), :approved?, "First should have been approved" + assert_not_predicate Topic.find(2), :approved?, "Second should have been unapproved" ensure Topic.connection.class_eval do remove_method :commit_db_transaction @@ -99,9 +163,11 @@ class TransactionTest < ActiveRecord::TestCase end end - Topic.transaction do - @first.approved = true - @first.save! + assert_not_called(@first, :committed!) do + Topic.transaction do + @first.approved = true + @first.save! + end end assert_equal 0, num @@ -113,19 +179,21 @@ class TransactionTest < ActiveRecord::TestCase end def test_successful_with_instance_method - @first.transaction do - @first.approved = true - @second.approved = false - @first.save - @second.save + assert_not_called(@first, :committed!) do + @first.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end end - assert Topic.find(1).approved?, "First should have been approved" - assert_not Topic.find(2).approved?, "Second should have been unapproved" + assert_predicate Topic.find(1), :approved?, "First should have been approved" + assert_not_predicate Topic.find(2), :approved?, "Second should have been unapproved" end def test_failing_on_exception - begin + assert_not_called(@first, :rolledback!) do Topic.transaction do @first.approved = true @second.approved = false @@ -137,11 +205,11 @@ class TransactionTest < ActiveRecord::TestCase # caught it end - assert @first.approved?, "First should still be changed in the objects" - assert_not @second.approved?, "Second should still be changed in the objects" + assert_predicate @first, :approved?, "First should still be changed in the objects" + assert_not_predicate @second, :approved?, "Second should still be changed in the objects" - assert_not Topic.find(1).approved?, "First shouldn't have been approved" - assert Topic.find(2).approved?, "Second should still be approved" + assert_not_predicate Topic.find(1), :approved?, "First shouldn't have been approved" + assert_predicate Topic.find(2), :approved?, "Second should still be approved" end def test_raising_exception_in_callback_rollbacks_in_save @@ -150,8 +218,10 @@ class TransactionTest < ActiveRecord::TestCase end @first.approved = true - e = assert_raises(RuntimeError) { @first.save } - assert_equal "Make the transaction rollback", e.message + assert_not_called(@first, :rolledback!) do + e = assert_raises(RuntimeError) { @first.save } + assert_equal "Make the transaction rollback", e.message + end assert_not_predicate Topic.find(1), :approved? end @@ -159,13 +229,15 @@ class TransactionTest < ActiveRecord::TestCase def @first.before_save_for_transaction raise ActiveRecord::Rollback end - assert_not @first.approved + assert_not_predicate @first, :approved? - Topic.transaction do - @first.approved = true - @first.save! + assert_not_called(@first, :rolledback!) do + Topic.transaction do + @first.approved = true + @first.save! + end end - assert_not Topic.find(@first.id).approved?, "Should not commit the approved flag" + assert_not_predicate Topic.find(@first.id), :approved?, "Should not commit the approved flag" end def test_raising_exception_in_nested_transaction_restore_state_in_save @@ -175,11 +247,13 @@ class TransactionTest < ActiveRecord::TestCase raise "Make the transaction rollback" end - assert_raises(RuntimeError) do - Topic.transaction { topic.save } + assert_not_called(topic, :rolledback!) do + assert_raises(RuntimeError) do + Topic.transaction { topic.save } + end end - assert topic.new_record?, "#{topic.inspect} should be new record" + assert_predicate topic, :new_record?, "#{topic.inspect} should be new record" end def test_transaction_state_is_cleared_when_record_is_persisted diff --git a/activerecord/test/cases/unconnected_test.rb b/activerecord/test/cases/unconnected_test.rb index f0a0e7f805..49746996bc 100644 --- a/activerecord/test/cases/unconnected_test.rb +++ b/activerecord/test/cases/unconnected_test.rb @@ -11,6 +11,12 @@ class TestUnconnectedAdapter < ActiveRecord::TestCase def setup @underlying = ActiveRecord::Base.connection @specification = ActiveRecord::Base.remove_connection + + # Clear out connection info from other pids (like a fork parent) too + pool_map = ActiveRecord::Base.connection_handler.instance_variable_get(:@owner_to_pool) + (pool_map.keys - [Process.pid]).each do |other_pid| + pool_map.delete(other_pid) + end end teardown do diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb index 703c24b340..993c201f03 100644 --- a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb +++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb @@ -4,16 +4,20 @@ require "cases/helper" require "models/topic" class I18nGenerateMessageValidationTest < ActiveRecord::TestCase + class Backend < I18n::Backend::Simple + include I18n::Backend::Fallbacks + end + def setup Topic.clear_validators! @topic = Topic.new - I18n.backend = I18n::Backend::Simple.new + I18n.backend = Backend.new end def reset_i18n_load_path @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend I18n.load_path.clear - I18n.backend = I18n::Backend::Simple.new + I18n.backend = Backend.new yield ensure I18n.load_path.replace @old_load_path @@ -83,4 +87,16 @@ class I18nGenerateMessageValidationTest < ActiveRecord::TestCase assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title") end end + + test "activerecord attributes scope falls back to parent locale before it falls back to the :errors namespace" do + reset_i18n_load_path do + I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { attributes: { title: { taken: "custom en message" } } } } } } + I18n.backend.store_translations "en-US", errors: { messages: { taken: "generic en-US fallback" } } + + I18n.with_locale "en-US" do + assert_equal "custom en message", @topic.errors.generate_message(:title, :taken, value: "title") + assert_equal "generic en-US fallback", @topic.errors.generate_message(:heading, :taken, value: "heading") + end + end + end end diff --git a/activerecord/test/cases/validations/i18n_validation_test.rb b/activerecord/test/cases/validations/i18n_validation_test.rb index b7c52ea18c..2645776ab7 100644 --- a/activerecord/test/cases/validations/i18n_validation_test.rb +++ b/activerecord/test/cases/validations/i18n_validation_test.rb @@ -40,11 +40,11 @@ class I18nValidationTest < ActiveRecord::TestCase COMMON_CASES = [ # [ case, validation_options, generate_message_options] [ "given no options", {}, {}], - [ "given custom message", { message: "custom" }, { message: "custom" }], - [ "given if condition", { if: lambda { true } }, {}], - [ "given unless condition", { unless: lambda { false } }, {}], - [ "given option that is not reserved", { format: "jpg" }, { format: "jpg" }], - [ "given on condition", { on: [:create, :update] }, {}] + [ "given custom message", { message: "custom" }, { message: "custom" }], + [ "given if condition", { if: lambda { true } }, {}], + [ "given unless condition", { unless: lambda { false } }, {}], + [ "given option that is not reserved", { format: "jpg" }, { format: "jpg" }], + [ "given on condition", { on: [:create, :update] }, {}] ] COMMON_CASES.each do |name, validation_options, generate_message_options| @@ -53,6 +53,7 @@ class I18nValidationTest < ActiveRecord::TestCase @topic.title = unique_topic.title assert_called_with(@topic.errors, :generate_message, [:title, :taken, generate_message_options.merge(value: "unique!")]) do @topic.valid? + @topic.errors.messages end end end @@ -62,6 +63,7 @@ class I18nValidationTest < ActiveRecord::TestCase Topic.validates_associated :replies, validation_options assert_called_with(replied_topic.errors, :generate_message, [:replies, :invalid, generate_message_options.merge(value: replied_topic.replies)]) do replied_topic.save + replied_topic.errors.messages end end end diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb index 8f6f47e5fb..76163e3093 100644 --- a/activerecord/test/cases/validations/uniqueness_validation_test.rb +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -314,6 +314,51 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert t3.save, "Should save t3 as unique" end + if current_adapter?(:Mysql2Adapter) + def test_deprecate_validate_uniqueness_mismatched_collation + Topic.validates_uniqueness_of(:author_email_address) + + topic1 = Topic.new(author_email_address: "david@loudthinking.com") + topic2 = Topic.new(author_email_address: "David@loudthinking.com") + + assert_equal 1, Topic.where(author_email_address: "david@loudthinking.com").count + + assert_deprecated do + assert_not topic1.valid? + assert_not topic1.save + assert topic2.valid? + assert topic2.save + end + + assert_equal 2, Topic.where(author_email_address: "david@loudthinking.com").count + assert_equal 2, Topic.where(author_email_address: "David@loudthinking.com").count + end + end + + def test_validate_case_sensitive_uniqueness_by_default + Topic.validates_uniqueness_of(:author_email_address) + + topic1 = Topic.new(author_email_address: "david@loudthinking.com") + topic2 = Topic.new(author_email_address: "David@loudthinking.com") + + assert_equal 1, Topic.where(author_email_address: "david@loudthinking.com").count + + ActiveSupport::Deprecation.silence do + assert_not topic1.valid? + assert_not topic1.save + assert topic2.valid? + assert topic2.save + end + + if current_adapter?(:Mysql2Adapter) + assert_equal 2, Topic.where(author_email_address: "david@loudthinking.com").count + assert_equal 2, Topic.where(author_email_address: "David@loudthinking.com").count + else + assert_equal 1, Topic.where(author_email_address: "david@loudthinking.com").count + assert_equal 1, Topic.where(author_email_address: "David@loudthinking.com").count + end + end + def test_validate_case_sensitive_uniqueness Topic.validates_uniqueness_of(:title, case_sensitive: true, allow_nil: true) @@ -510,7 +555,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase abc.save! end assert_match(/\AUnknown primary key for table dashboards in model/, e.message) - assert_match(/Can not validate uniqueness for persisted record without primary key.\z/, e.message) + assert_match(/Cannot validate uniqueness for persisted record without primary key.\z/, e.message) end def test_validate_uniqueness_ignores_itself_when_primary_key_changed diff --git a/activerecord/test/cases/validations_test.rb b/activerecord/test/cases/validations_test.rb index 9a70934b7e..4f98a6b7fc 100644 --- a/activerecord/test/cases/validations_test.rb +++ b/activerecord/test/cases/validations_test.rb @@ -145,15 +145,17 @@ class ValidationsTest < ActiveRecord::TestCase end def test_validates_acceptance_of_with_undefined_attribute_methods - Topic.validates_acceptance_of(:approved) - topic = Topic.new(approved: true) - Topic.undefine_attribute_methods + klass = Class.new(Topic) + klass.validates_acceptance_of(:approved) + topic = klass.new(approved: true) + klass.undefine_attribute_methods assert topic.approved end def test_validates_acceptance_of_as_database_column - Topic.validates_acceptance_of(:approved) - topic = Topic.create("approved" => true) + klass = Class.new(Topic) + klass.validates_acceptance_of(:approved) + topic = klass.create("approved" => true) assert topic["approved"] end diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 8b5a2fa0c8..b52b643ad7 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -153,6 +153,7 @@ class Author < ActiveRecord::Base has_many :comments_on_posts_with_default_include, through: :posts_with_default_include, source: :comments has_many :posts_with_signature, ->(record) { where("posts.title LIKE ?", "%by #{record.name.downcase}%") }, class_name: "Post" + has_many :posts_mentioning_author, ->(record = nil) { where("posts.body LIKE ?", "%#{record&.name&.downcase}%") }, class_name: "Post" has_many :posts_with_extension, -> { order(:title) }, class_name: "Post" do def extension_method; end @@ -220,3 +221,12 @@ class AuthorFavorite < ActiveRecord::Base belongs_to :author belongs_to :favorite_author, class_name: "Author" end + +class AuthorFavoriteWithScope < ActiveRecord::Base + self.table_name = "author_favorites" + + default_scope { order(id: :asc) } + + belongs_to :author + belongs_to :favorite_author, class_name: "Author" +end diff --git a/activerecord/test/models/bird.rb b/activerecord/test/models/bird.rb index c9f6759c7d..20af7c6122 100644 --- a/activerecord/test/models/bird.rb +++ b/activerecord/test/models/bird.rb @@ -17,8 +17,8 @@ class Bird < ActiveRecord::Base throw(:abort) end - attr_accessor :total_count + attr_accessor :total_count, :enable_count after_initialize do - self.total_count = Bird.count + self.total_count = Bird.count if enable_count end end diff --git a/activerecord/test/models/category.rb b/activerecord/test/models/category.rb index 2ccc00bed9..8c86879dc6 100644 --- a/activerecord/test/models/category.rb +++ b/activerecord/test/models/category.rb @@ -26,6 +26,7 @@ class Category < ActiveRecord::Base has_many :categorizations has_many :special_categorizations has_many :post_comments, through: :posts, source: :comments + has_many :ordered_post_comments, -> { order(id: :desc) }, through: :posts, source: :comments has_many :authors, through: :categorizations has_many :authors_with_select, -> { select "authors.*, categorizations.post_id" }, through: :categorizations, source: :author diff --git a/activerecord/test/models/club.rb b/activerecord/test/models/club.rb index 13e72e9c50..bb49fb300c 100644 --- a/activerecord/test/models/club.rb +++ b/activerecord/test/models/club.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Club < ActiveRecord::Base - has_one :membership + has_one :membership, touch: true has_many :memberships, inverse_of: false has_many :members, through: :memberships has_one :sponsor @@ -12,6 +12,8 @@ class Club < ActiveRecord::Base scope :general, -> { left_joins(:category).where(categories: { name: "General" }).unscope(:limit) } + accepts_nested_attributes_for :membership + private def private_method diff --git a/activerecord/test/models/company.rb b/activerecord/test/models/company.rb index 838f515aad..a0f48d23f1 100644 --- a/activerecord/test/models/company.rb +++ b/activerecord/test/models/company.rb @@ -13,6 +13,8 @@ class Company < AbstractCompany has_many :contracts has_many :developers, through: :contracts + attribute :metadata, :json + scope :of_first_firm, lambda { joins(account: :firm). where("firms.id" => 1) diff --git a/activerecord/test/models/contract.rb b/activerecord/test/models/contract.rb index 3f663375c4..89719775c4 100644 --- a/activerecord/test/models/contract.rb +++ b/activerecord/test/models/contract.rb @@ -5,7 +5,9 @@ class Contract < ActiveRecord::Base belongs_to :developer, primary_key: :id belongs_to :firm, foreign_key: "company_id" - before_save :hi + attribute :metadata, :json + + before_save :hi, :update_metadata after_save :bye attr_accessor :hi_count, :bye_count @@ -19,6 +21,10 @@ class Contract < ActiveRecord::Base @bye_count ||= 0 @bye_count += 1 end + + def update_metadata + self.metadata = { company_id: company_id, developer_id: developer_id } + end end class NewContract < Contract diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb index ec48094207..92d01ba338 100644 --- a/activerecord/test/models/developer.rb +++ b/activerecord/test/models/developer.rb @@ -2,13 +2,13 @@ require "ostruct" -module DeveloperProjectsAssociationExtension2 - def find_least_recent - order("id ASC").first +class Developer < ActiveRecord::Base + module ProjectsAssociationExtension2 + def find_least_recent + order("id ASC").first + end end -end -class Developer < ActiveRecord::Base self.ignored_columns = %w(first_name last_name) has_and_belongs_to_many :projects do @@ -24,19 +24,19 @@ class Developer < ActiveRecord::Base has_and_belongs_to_many :shared_computers, class_name: "Computer" has_and_belongs_to_many :projects_extended_by_name, - -> { extending(DeveloperProjectsAssociationExtension) }, + -> { extending(ProjectsAssociationExtension) }, class_name: "Project", join_table: "developers_projects", association_foreign_key: "project_id" has_and_belongs_to_many :projects_extended_by_name_twice, - -> { extending(DeveloperProjectsAssociationExtension, DeveloperProjectsAssociationExtension2) }, + -> { extending(ProjectsAssociationExtension, ProjectsAssociationExtension2) }, class_name: "Project", join_table: "developers_projects", association_foreign_key: "project_id" has_and_belongs_to_many :projects_extended_by_name_and_block, - -> { extending(DeveloperProjectsAssociationExtension) }, + -> { extending(ProjectsAssociationExtension) }, class_name: "Project", join_table: "developers_projects", association_foreign_key: "project_id" do @@ -207,6 +207,7 @@ end class MultiplePoorDeveloperCalledJamis < ActiveRecord::Base self.table_name = "developers" + default_scope { } default_scope -> { where(name: "Jamis") } default_scope -> { where(salary: 50000) } end diff --git a/activerecord/test/models/pirate.rb b/activerecord/test/models/pirate.rb index fd5083e597..8733398697 100644 --- a/activerecord/test/models/pirate.rb +++ b/activerecord/test/models/pirate.rb @@ -98,3 +98,19 @@ class FamousPirate < ActiveRecord::Base has_many :famous_ships validates_presence_of :catchphrase, on: :conference end + +class SpacePirate < ActiveRecord::Base + self.table_name = "pirates" + + belongs_to :parrot + belongs_to :parrot_with_annotation, -> { annotate("that tells jokes") }, class_name: :Parrot, foreign_key: :parrot_id + has_and_belongs_to_many :parrots, foreign_key: :pirate_id + has_and_belongs_to_many :parrots_with_annotation, -> { annotate("that are very colorful") }, class_name: :Parrot, foreign_key: :pirate_id + has_one :ship, foreign_key: :pirate_id + has_one :ship_with_annotation, -> { annotate("that is a rocket") }, class_name: :Ship, foreign_key: :pirate_id + has_many :birds, foreign_key: :pirate_id + has_many :birds_with_annotation, -> { annotate("that are also parrots") }, class_name: :Bird, foreign_key: :pirate_id + has_many :treasures, as: :looter + has_many :treasure_estimates, through: :treasures, source: :price_estimates + has_many :treasure_estimates_with_annotation, -> { annotate("yarrr") }, through: :treasures, source: :price_estimates +end diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index e32cc59399..50c0dddcf2 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -11,6 +11,10 @@ class Post < ActiveRecord::Base def author "lifo" end + + def greeting + super + " :)" + end end module NamedExtension2 @@ -31,6 +35,7 @@ class Post < ActiveRecord::Base belongs_to :author_with_posts, -> { includes(:posts) }, class_name: "Author", foreign_key: :author_id belongs_to :author_with_address, -> { includes(:author_address) }, class_name: "Author", foreign_key: :author_id + belongs_to :author_with_select, -> { select(:id) }, class_name: "Author", foreign_key: :author_id def first_comment super.body @@ -38,6 +43,7 @@ class Post < ActiveRecord::Base has_one :first_comment, -> { order("id ASC") }, class_name: "Comment" has_one :last_comment, -> { order("id desc") }, class_name: "Comment" + scope :no_comments, -> { left_joins(:comments).where(comments: { id: nil }) } scope :with_special_comments, -> { joins(:comments).where(comments: { type: "SpecialComment" }) } scope :with_very_special_comments, -> { joins(:comments).where(comments: { type: "VerySpecialComment" }) } scope :with_post, ->(post_id) { joins(:comments).where(comments: { post_id: post_id }) } @@ -77,6 +83,7 @@ class Post < ActiveRecord::Base has_many :comments_with_extend_2, extend: [NamedExtension, NamedExtension2], class_name: "Comment", foreign_key: "post_id" has_many :author_favorites, through: :author + has_many :author_favorites_with_scope, through: :author, class_name: "AuthorFavoriteWithScope", source: "author_favorites" has_many :author_categorizations, through: :author, source: :categorizations has_many :author_addresses, through: :author has_many :author_address_extra_with_address, @@ -201,6 +208,10 @@ end class SubAbstractStiPost < AbstractStiPost; end +class NullPost < Post + default_scope { none } +end + class FirstPost < ActiveRecord::Base self.inheritance_column = :disabled self.table_name = "posts" @@ -210,6 +221,12 @@ class FirstPost < ActiveRecord::Base has_one :comment, foreign_key: :post_id end +class PostWithDefaultSelect < ActiveRecord::Base + self.table_name = "posts" + + default_scope { select(:author_id) } +end + class TaggedPost < Post has_many :taggings, -> { rewhere(taggable_type: "TaggedPost") }, as: :taggable has_many :tags, through: :taggings @@ -307,8 +324,8 @@ class FakeKlass "posts" end - def attribute_alias?(name) - false + def attribute_aliases + {} end def sanitize_sql(sql) @@ -327,6 +344,10 @@ class FakeKlass # noop end + def columns_hash + { "name" => nil } + end + def arel_table Post.arel_table end diff --git a/activerecord/test/models/rating.rb b/activerecord/test/models/rating.rb index cf06bc6931..49aa38285f 100644 --- a/activerecord/test/models/rating.rb +++ b/activerecord/test/models/rating.rb @@ -3,4 +3,5 @@ class Rating < ActiveRecord::Base belongs_to :comment has_many :taggings, as: :taggable + has_many :taggings_without_tag, -> { left_joins(:tag).where("tags.id": nil) }, as: :taggable, class_name: "Tagging" end diff --git a/activerecord/test/models/reply.rb b/activerecord/test/models/reply.rb index 0807bcf875..f6ab9c8a8f 100644 --- a/activerecord/test/models/reply.rb +++ b/activerecord/test/models/reply.rb @@ -7,6 +7,8 @@ class Reply < Topic belongs_to :topic_with_primary_key, class_name: "Topic", primary_key: "title", foreign_key: "parent_title", counter_cache: "replies_count", touch: true has_many :replies, class_name: "SillyReply", dependent: :destroy, foreign_key: "parent_id" has_many :silly_unique_replies, dependent: :destroy, foreign_key: "parent_id" + + scope :ordered, -> { Reply.order(:id) } end class SillyReply < Topic @@ -32,29 +34,29 @@ class WrongReply < Reply validate :check_author_name_is_secret, on: :special_case def check_empty_title - errors[:title] << "Empty" unless attribute_present?("title") + errors.add(:title, "Empty") unless attribute_present?("title") end def errors_on_empty_content - errors[:content] << "Empty" unless attribute_present?("content") + errors.add(:content, "Empty") unless attribute_present?("content") end def check_content_mismatch if attribute_present?("title") && attribute_present?("content") && content == "Mismatch" - errors[:title] << "is Content Mismatch" + errors.add(:title, "is Content Mismatch") end end def title_is_wrong_create - errors[:title] << "is Wrong Create" if attribute_present?("title") && title == "Wrong Create" + errors.add(:title, "is Wrong Create") if attribute_present?("title") && title == "Wrong Create" end def check_wrong_update - errors[:title] << "is Wrong Update" if attribute_present?("title") && title == "Wrong Update" + errors.add(:title, "is Wrong Update") if attribute_present?("title") && title == "Wrong Update" end def check_author_name_is_secret - errors[:author_name] << "Invalid" unless author_name == "secret" + errors.add(:author_name, "Invalid") unless author_name == "secret" end end diff --git a/activerecord/test/models/section.rb b/activerecord/test/models/section.rb new file mode 100644 index 0000000000..f8b4cc7936 --- /dev/null +++ b/activerecord/test/models/section.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Section < ActiveRecord::Base + belongs_to :session, inverse_of: :sections, autosave: true + belongs_to :seminar, inverse_of: :sections, autosave: true +end diff --git a/activerecord/test/models/seminar.rb b/activerecord/test/models/seminar.rb new file mode 100644 index 0000000000..c18aa86433 --- /dev/null +++ b/activerecord/test/models/seminar.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Seminar < ActiveRecord::Base + has_many :sections, inverse_of: :seminar, autosave: true, dependent: :destroy + has_many :sessions, through: :sections +end diff --git a/activerecord/test/models/session.rb b/activerecord/test/models/session.rb new file mode 100644 index 0000000000..db66b5297e --- /dev/null +++ b/activerecord/test/models/session.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Session < ActiveRecord::Base + has_many :sections, inverse_of: :session, autosave: true, dependent: :destroy + has_many :seminars, through: :sections +end diff --git a/activerecord/test/models/topic.rb b/activerecord/test/models/topic.rb index fdb461ed7f..77101090f2 100644 --- a/activerecord/test/models/topic.rb +++ b/activerecord/test/models/topic.rb @@ -10,9 +10,6 @@ class Topic < ActiveRecord::Base scope :approved, -> { where(approved: true) } scope :rejected, -> { where(approved: false) } - scope :children, -> { where.not(parent_id: nil) } - scope :has_children, -> { where(id: Topic.children.select(:parent_id)) } - scope :scope_with_lambda, lambda { all } scope :by_lifo, -> { where(author_name: "lifo") } @@ -102,7 +99,7 @@ class Topic < ActiveRecord::Base end def destroy_children - self.class.where("parent_id = #{id}").delete_all + self.class.delete_by(parent_id: id) end def set_email_address @@ -122,10 +119,6 @@ class Topic < ActiveRecord::Base end end -class ImportantTopic < Topic - serialize :important, Hash -end - class DefaultRejectedTopic < Topic default_scope -> { where(approved: false) } end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 86d5a67a13..41920b3719 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -8,10 +8,18 @@ ActiveRecord::Schema.define do # # # ------------------------------------------------------------------- # + case_sensitive_options = + if current_adapter?(:Mysql2Adapter) + { collation: "utf8mb4_bin" } + else + {} + end + create_table :accounts, force: true do |t| t.references :firm, index: false t.string :firm_name t.integer :credit_limit + t.integer "a" * max_identifier_length end create_table :admin_accounts, force: true do |t| @@ -94,18 +102,23 @@ ActiveRecord::Schema.define do end create_table :books, id: :integer, force: true do |t| + default_zero = { default: 0 } t.references :author t.string :format t.column :name, :string - t.column :status, :integer, default: 0 - t.column :read_status, :integer, default: 0 + t.column :status, :integer, **default_zero + t.column :read_status, :integer, **default_zero t.column :nullable_status, :integer - t.column :language, :integer, default: 0 - t.column :author_visibility, :integer, default: 0 - t.column :illustrator_visibility, :integer, default: 0 - t.column :font_size, :integer, default: 0 - t.column :difficulty, :integer, default: 0 + t.column :language, :integer, **default_zero + t.column :author_visibility, :integer, **default_zero + t.column :illustrator_visibility, :integer, **default_zero + t.column :font_size, :integer, **default_zero + t.column :difficulty, :integer, **default_zero t.column :cover, :string, default: "hard" + t.string :isbn + t.datetime :published_on + t.index [:author_id, :name], unique: true + t.index :isbn, where: "published_on IS NOT NULL", unique: true end create_table :booleans, force: true do |t| @@ -247,6 +260,7 @@ ActiveRecord::Schema.define do create_table :contracts, force: true do |t| t.references :developer, index: false t.references :company, index: false + t.string :metadata end create_table :customers, force: true do |t| @@ -264,7 +278,7 @@ ActiveRecord::Schema.define do end create_table :dashboards, force: true, id: false do |t| - t.string :dashboard_id + t.string :dashboard_id, **case_sensitive_options t.string :name end @@ -328,7 +342,7 @@ ActiveRecord::Schema.define do end create_table :essays, force: true do |t| - t.string :name + t.string :name, **case_sensitive_options t.string :writer_id t.string :writer_type t.string :category_id @@ -336,7 +350,7 @@ ActiveRecord::Schema.define do end create_table :events, force: true do |t| - t.string :title, limit: 5 + t.string :title, limit: 5, **case_sensitive_options end create_table :eyes, force: true do |t| @@ -378,7 +392,7 @@ ActiveRecord::Schema.define do end create_table :guids, force: true do |t| - t.column :key, :string + t.column :key, :string, **case_sensitive_options end create_table :guitars, force: true do |t| @@ -386,8 +400,8 @@ ActiveRecord::Schema.define do end create_table :inept_wizards, force: true do |t| - t.column :name, :string, null: false - t.column :city, :string, null: false + t.column :name, :string, null: false, **case_sensitive_options + t.column :city, :string, null: false, **case_sensitive_options t.column :type, :string end @@ -510,6 +524,8 @@ ActiveRecord::Schema.define do t.integer :club_id, :member_id t.boolean :favourite, default: false t.integer :type + t.datetime :created_at + t.datetime :updated_at end create_table :member_types, force: true do |t| @@ -778,6 +794,24 @@ ActiveRecord::Schema.define do t.integer :lock_version, default: 0 end + disable_referential_integrity do + create_table :seminars, force: :cascade do |t| + t.string :name + end + + create_table :sessions, force: :cascade do |t| + t.date :start_date + t.date :end_date + t.string :name + end + + create_table :sections, force: :cascade do |t| + t.string :short_name + t.belongs_to :session, foreign_key: true + t.belongs_to :seminar, foreign_key: true + end + end + create_table :shape_expressions, force: true do |t| t.string :paint_type t.integer :paint_id @@ -874,8 +908,8 @@ ActiveRecord::Schema.define do end create_table :topics, force: true do |t| - t.string :title, limit: 250 - t.string :author_name + t.string :title, limit: 250, **case_sensitive_options + t.string :author_name, **case_sensitive_options t.string :author_email_address if subsecond_precision_supported? t.datetime :written_on, precision: 6 @@ -887,10 +921,10 @@ ActiveRecord::Schema.define do # use VARCHAR2(4000) instead of CLOB datatype as CLOB data type has many limitations in # Oracle SELECT WHERE clause which causes many unit test failures if current_adapter?(:OracleAdapter) - t.string :content, limit: 4000 + t.string :content, limit: 4000, **case_sensitive_options t.string :important, limit: 4000 else - t.text :content + t.text :content, **case_sensitive_options t.text :important end t.boolean :approved, default: true diff --git a/activerecord/test/support/stubs/strong_parameters.rb b/activerecord/test/support/stubs/strong_parameters.rb index 84f93a28b9..da8f9892f9 100644 --- a/activerecord/test/support/stubs/strong_parameters.rb +++ b/activerecord/test/support/stubs/strong_parameters.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true -class Parameters +require "active_support/core_ext/hash/indifferent_access" + +class ProtectedParams + delegate :keys, :key?, :has_key?, :empty?, to: :@parameters + def initialize(parameters = {}) @parameters = parameters.with_indifferent_access @permitted = false @@ -15,7 +19,22 @@ class Parameters self end + def [](key) + @parameters[key] + end + def to_h @parameters.to_h end + alias to_unsafe_h to_h + + def stringify_keys + dup + end + + def dup + super.tap do |duplicate| + duplicate.instance_variable_set :@permitted, @permitted + end + end end |