diff options
Diffstat (limited to 'activerecord')
160 files changed, 2278 insertions, 1862 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 37f31462b3..f708d42e43 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,795 +1,3 @@ -* Assign all attributes before calling `build` to ensure the child record is visible in - `before_add` and `after_add` callbacks for `has_many :through` associations. - Fixes #33249. - *Ryan H. Kerr* - -* Add `ActiveRecord::Relation#extract_associated` for extracting associated records from a relation. - - ``` - account.memberships.extract_associated(:user) - # => Returns collection of User records - ``` - - *DHH* - -* Add `ActiveRecord::Relation#annotate` for adding SQL comments to its queries. - - For example: - - ``` - Post.where(id: 123).annotate("this is a comment").to_sql - # SELECT "posts".* FROM "posts" WHERE "posts"."id" = 123 /* this is a comment */ - ``` - - This can be useful in instrumentation or other analysis of issued queries. - - *Matt Yoho* - -* Support Optimizer Hints. - - In most databases, a way to control the optimizer is by using optimizer hints, - which can be specified within individual statements. - - 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" - - See also: - - * https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html - * https://pghintplan.osdn.jp/pg_hint_plan.html - * https://docs.oracle.com/en/database/oracle/oracle-database/12.2/tgsql/influencing-the-optimizer.html - * https://docs.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=sql-server-2017 - * https://www.ibm.com/support/knowledgecenter/en/SSEPGG_11.1.0/com.ibm.db2.luw.admin.perf.doc/doc/c0070117.html - - *Ryuta Kamizono* - -* Fix query attribute method on user-defined attribute to be aware of typecasted value. - - For example, the following code no longer return false as casted non-empty string: - - ``` - class Post < ActiveRecord::Base - attribute :user_defined_text, :text - end - - Post.new(user_defined_text: "false").user_defined_text? # => true - ``` - - *Yuji Kamijima* - -* Quote empty ranges like other empty enumerables. - - *Patrick Rebsch* - -* Add `insert_all`/`insert_all!`/`upsert_all` methods to `ActiveRecord::Persistence`, - allowing bulk inserts akin to the bulk updates provided by `update_all` and - bulk deletes by `delete_all`. - - Supports skipping or upserting duplicates through the `ON CONFLICT` syntax - for Postgres (9.5+) and Sqlite (3.24+) and `ON DUPLICATE KEY UPDATE` syntax - for MySQL. - - *Bob Lail* - -* Add `rails db:seed:replant` that truncates tables of each database - for current environment and loads the seeds. - - *bogdanvlviv*, *DHH* - -* Add `ActiveRecord::Base.connection.truncate` for SQLite3 adapter. - - *bogdanvlviv* - -* Deprecate mismatched collation comparison for uniqueness validator. - - Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. - To continue case sensitive comparison on the case insensitive column, - pass `case_sensitive: true` option explicitly to the uniqueness validator. - - *Ryuta Kamizono* - -* Add `reselect` method. This is a short-hand for `unscope(:select).select(fields)`. - - Fixes #27340. - - *Willian Gustavo Veiga* - -* Add negative scopes for all enum values. - - Example: - - class Post < ActiveRecord::Base - enum status: %i[ drafted active trashed ] - end - - Post.not_drafted # => where.not(status: :drafted) - Post.not_active # => where.not(status: :active) - Post.not_trashed # => where.not(status: :trashed) - - *DHH* - -* Fix different `count` calculation when using `size` with manual `select` with DISTINCT. - - Fixes #35214. - - *Juani Villarejo* - - -## Rails 6.0.0.beta3 (March 11, 2019) ## - -* No changes. - - -## Rails 6.0.0.beta2 (February 25, 2019) ## - -* Fix prepared statements caching to be enabled even when query caching is enabled. - - *Ryuta Kamizono* - -* Ensure `update_all` series cares about optimistic locking. - - *Ryuta Kamizono* - -* Don't allow `where` with non numeric string matches to 0 values. - - *Ryuta Kamizono* - -* Introduce `ActiveRecord::Relation#destroy_by` and `ActiveRecord::Relation#delete_by`. - - `destroy_by` allows relation to find all the records matching the condition and perform - `destroy_all` on the matched records. - - Example: - - Person.destroy_by(name: 'David') - Person.destroy_by(name: 'David', rating: 4) - - david = Person.find_by(name: 'David') - david.posts.destroy_by(id: [1, 2, 3]) - - `delete_by` allows relation to find all the records matching the condition and perform - `delete_all` on the matched records. - - Example: - - Person.delete_by(name: 'David') - Person.delete_by(name: 'David', rating: 4) - - david = Person.find_by(name: 'David') - david.posts.delete_by(id: [1, 2, 3]) - - *Abhay Nikam* - -* Don't allow `where` with invalid value matches to nil values. - - Fixes #33624. - - *Ryuta Kamizono* - -* SQLite3: Implement `add_foreign_key` and `remove_foreign_key`. - - *Ryuta Kamizono* - -* Deprecate using class level querying methods if the receiver scope - regarded as leaked. Use `klass.unscoped` to avoid the leaking scope. - - *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/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/association.rb b/activerecord/lib/active_record/associations/association.rb index 0bb63b97ae..cf22b850b9 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -225,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 5848cd9112..e53665ce82 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -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_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index edcb44f0fc..85e0f076da 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -1002,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/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index c7cd87f9d4..6b57e5093a 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -150,7 +150,6 @@ module ActiveRecord 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 diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index 46532f651e..342d9e7a5a 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -36,13 +36,7 @@ module ActiveRecord def preloaded_records return @preloaded_records if defined?(@preloaded_records) - 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) - end + @preloaded_records = owner_keys.empty? ? [] : records_for(owner_keys) end private diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index af7e46e649..220043c061 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -24,7 +24,7 @@ module ActiveRecord RESTRICTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) - class GeneratedAttributeMethodsBuilder < Module #:nodoc: + class GeneratedAttributeMethods < Module #:nodoc: include Mutex_m end @@ -35,7 +35,7 @@ module ActiveRecord end def initialize_generated_modules # :nodoc: - @generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethodsBuilder.new) + @generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethods.new) private_constant :GeneratedAttributeMethods @attribute_methods_generated = false include @generated_attribute_methods @@ -89,7 +89,7 @@ module ActiveRecord # If ThisClass < ... < SomeSuperClass < ... < Base and SomeSuperClass # defines its own attribute method, then we don't want to overwrite that. defined = method_defined_within?(method_name, superclass, Base) && - ! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethodsBuilder) + ! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethods) defined || super end end @@ -197,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()." @@ -465,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 6811f54b10..0cf67644af 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -32,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/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index e05b7cd6d2..8d89e7d84a 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -377,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) } @@ -392,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? 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/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 0ded1a5318..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 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 6aacbe5f88..ef19538447 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -131,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 @@ -427,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 @@ -488,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 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 93b1c4e632..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! 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 4861872129..688eea75e8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -416,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) 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 6981ea6ecd..2b64e96450 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -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)}" @@ -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? @@ -1097,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})" @@ -1185,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 @@ -1374,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_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 7aad306d50..bf0bb84c93 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -133,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? @@ -172,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) @@ -575,9 +576,17 @@ module ActiveRecord "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 check_version - end def type_map @type_map ||= Type::TypeMap.new.tap do |mapping| 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 8ca2cfa9ed..282b2b1838 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -55,24 +55,26 @@ module ActiveRecord super(connection, logger, config) 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? @@ -96,16 +98,16 @@ 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? && version >= "5.7.7" + !mariadb? && database_version >= "5.7.7" end def supports_advisory_locks? @@ -285,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 @@ -356,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 @@ -516,8 +505,8 @@ module ActiveRecord sql = +"INSERT #{insert.into} #{insert.values_list}" if insert.skip_duplicates? - any_column = quote_column_name(insert.model.columns.first.name) - sql << " ON DUPLICATE KEY UPDATE #{any_column}=#{any_column}" + 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(",") @@ -526,12 +515,13 @@ module ActiveRecord sql end - 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 + 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 + + private def initialize_type_map(m = type_map) super @@ -702,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 @@ -800,8 +790,8 @@ module ActiveRecord 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/mysql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb index 1199c0ad1b..2132e5d248 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -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 @@ -145,15 +147,12 @@ module ActiveRecord elsif previous_packet.nil? true else - (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet + (current_packet.bytesize + previous_packet.bytesize + 2) > max_allowed_packet end end def max_allowed_packet - @max_allowed_packet ||= begin - bytes_margin = 2 - show_variable("max_allowed_packet") - bytes_margin - end + @max_allowed_packet ||= show_variable("max_allowed_packet") end def exec_stmt_and_free(sql, name, binds, cache_stmt: false) 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 4018f0815c..25a1fb234a 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -121,14 +121,18 @@ module ActiveRecord 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 @@ -170,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 @@ -240,7 +243,7 @@ module ActiveRecord when nil, 0x100..0xffff; nil when 0x10000..0xffffff; "medium" when 0x1000000..0xffffffff; "long" - else raise ActiveRecordError, "No #{type} type has byte size #{limit}" + else raise ArgumentError, "No #{type} type has byte size #{limit}" end end end @@ -252,7 +255,7 @@ module ActiveRecord 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." + else raise ArgumentError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead." 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 ae7dbd2868..d872bd662f 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) 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 a38c1325c0..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}. The limit on binary can be at most 1GB - 1byte." + 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, "No text type has byte size #{limit}. 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 @@ -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 29f764e8f4..91318a0af1 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -201,7 +201,7 @@ module ActiveRecord end def supports_insert_on_conflict? - postgresql_version >= 90500 + database_version >= 90500 end alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? alias supports_insert_on_duplicate_update? supports_insert_on_conflict? @@ -344,7 +344,7 @@ module ActiveRecord end def supports_pgcrypto_uuid? - postgresql_version >= 90400 + database_version >= 90400 end def supports_optimizer_hints? @@ -400,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) @@ -424,9 +422,10 @@ 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 @@ -446,12 +445,13 @@ module ActiveRecord sql 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 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" diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index 07453b4403..dbfe1e4a34 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -26,21 +26,23 @@ module ActiveRecord end def encode_with(coder) - 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["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"] - @indexes = coder["indexes"] || {} - @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) @@ -91,6 +93,10 @@ module ActiveRecord @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 @@ -99,6 +105,7 @@ module ActiveRecord @data_sources.clear @indexes.clear @version = nil + @database_version = nil end def size @@ -117,11 +124,11 @@ module ActiveRecord 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, @indexes] + [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, database_version] end def marshal_load(array) - @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes = array + @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, @database_version = array @indexes = @indexes || {} 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 index 84dcae49b9..46ce1a15b5 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -4,6 +4,82 @@ 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", 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 + + private def execute_batch(sql, name = nil) if preventing_writes? && write_query?(sql) @@ -14,11 +90,15 @@ module ActiveRecord log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.execute_batch(sql) + @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? 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 e64e995e1a..e48f59b4f0 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -105,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 ff23a525b9..f5f5827d04 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -10,7 +10,7 @@ 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 @@ -111,7 +111,7 @@ module ActiveRecord end def supports_expression_index? - sqlite_version >= "3.9.0" + database_version >= "3.9.0" end def requires_reloading? @@ -135,7 +135,7 @@ module ActiveRecord end def supports_insert_on_conflict? - sqlite_version >= "3.24.0" + 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? @@ -204,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: @@ -397,6 +317,16 @@ module ActiveRecord 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 + private # See https://www.sqlite.org/limits.html, # the default value is 999 when not configured. @@ -404,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 @@ -527,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: 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 eb4b48bc37..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. @@ -175,8 +174,7 @@ 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 @@ -270,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 @@ -318,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 @@ -355,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? @@ -399,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 @@ -466,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 @@ -570,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 7431a1c759..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 diff --git a/activerecord/lib/active_record/dynamic_matchers.rb b/activerecord/lib/active_record/dynamic_matchers.rb index 3bb8c6f4e3..2c941b0008 100644 --- a/activerecord/lib/active_record/dynamic_matchers.rb +++ b/activerecord/lib/active_record/dynamic_matchers.rb @@ -53,7 +53,7 @@ module ActiveRecord @model = model @name = 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/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index f77bc2e3c1..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 = "beta3" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb index d30aee7c00..f6577dcbc4 100644 --- a/activerecord/lib/active_record/insert_all.rb +++ b/activerecord/lib/active_record/insert_all.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActiveRecord - class InsertAll + class InsertAll # :nodoc: attr_reader :model, :connection, :inserts, :keys attr_reader :on_duplicate, :returning, :unique_by @@ -21,7 +21,10 @@ module ActiveRecord end def execute - connection.exec_query to_sql, "Bulk Insert" + message = +"#{model} " + message << "Bulk " if inserts.many? + message << (on_duplicate == :update ? "Upsert" : "Insert") + connection.exec_query to_sql, message end def updatable_columns @@ -73,15 +76,11 @@ module ActiveRecord raise ArgumentError, "#{connection.class} does not support :returning" end - unless %i{ raise skip update }.member?(on_duplicate) - raise NotImplementedError, "#{on_duplicate.inspect} is an unknown value for :on_duplicate. Valid values are :raise, :skip, and :update" - end - - if on_duplicate == :skip && !connection.supports_insert_on_duplicate_skip? + if skip_duplicates? && !connection.supports_insert_on_duplicate_skip? raise ArgumentError, "#{connection.class} does not support skipping duplicates" end - if on_duplicate == :update && !connection.supports_insert_on_duplicate_update? + if update_duplicates? && !connection.supports_insert_on_duplicate_update? raise ArgumentError, "#{connection.class} does not support upsert" end @@ -115,7 +114,7 @@ module ActiveRecord class Builder attr_reader :model - delegate :skip_duplicates?, :update_duplicates?, to: :insert_all + 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 @@ -126,27 +125,26 @@ module ActiveRecord end def values_list - types = extract_types_from_columns_on(model.table_name, keys: insert_all.keys) + types = extract_types_from_columns_on(model.table_name, keys: keys) values_list = insert_all.map_key_with_value do |key, value| - bind = Relation::QueryAttribute.new(key, value, types[key]) - connection.with_yaml_fallback(bind.value_for_database) + connection.with_yaml_fallback(types[key].serialize(value)) end Arel::InsertManager.new.create_values_list(values_list).to_sql end def returning - quote_columns(insert_all.returning).join(",") if insert_all.returning + format_columns(insert_all.returning) if insert_all.returning end def conflict_target if index = insert_all.unique_by - sql = +"(#{quote_columns(index.columns).join(',')})" + sql = +"(#{format_columns(index.columns)})" sql << " WHERE #{index.where}" if index.where sql elsif update_duplicates? - "(#{quote_columns(insert_all.primary_keys).join(',')})" + "(#{format_columns(insert_all.primary_keys)})" end end @@ -158,7 +156,7 @@ module ActiveRecord attr_reader :connection, :insert_all def columns_list - quote_columns(insert_all.keys).join(",") + format_columns(insert_all.keys) end def extract_types_from_columns_on(table_name, keys:) @@ -170,6 +168,10 @@ module ActiveRecord 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 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/migration.rb b/activerecord/lib/active_record/migration.rb index 997b7f763a..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}") @@ -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 diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index 8e7f596076..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 @@ -244,6 +247,26 @@ module ActiveRecord [: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 abc939826b..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) diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 7705cefa59..adfd564695 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -85,14 +85,14 @@ module ActiveRecord # ==== Options # # [:returning] - # (Postgres-only) An array of attributes to return for all successfully + # (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] - # (Postgres and SQLite only) By default rows are considered to be unique + # (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>. @@ -107,9 +107,9 @@ module ActiveRecord # 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. + # 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 # @@ -154,7 +154,7 @@ module ActiveRecord # ==== Options # # [:returning] - # (Postgres-only) An array of attributes to return for all successfully + # (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 @@ -178,7 +178,7 @@ module ActiveRecord InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute end - # Updates or inserts (upserts) multiple records into the database in a + # 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. @@ -202,14 +202,14 @@ module ActiveRecord # ==== Options # # [:returning] - # (Postgres-only) An array of attributes to return for all successfully + # (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] - # (Postgres and SQLite only) By default rows are considered to be unique + # (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>. @@ -224,9 +224,9 @@ module ActiveRecord # 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. + # 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 # @@ -238,7 +238,7 @@ module ActiveRecord # { title: "Eloquent Ruby", author: "Russ", isbn: "1" } # ], unique_by: :isbn) # - # Book.find_by(isbn: "1").title # => "Eloquent Ruby" + # 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 @@ -353,6 +353,7 @@ module ActiveRecord end def _insert_record(values) # :nodoc: + primary_key = self.primary_key primary_key_value = nil if primary_key && Hash === values @@ -423,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 @@ -530,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 @@ -568,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) @@ -665,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 @@ -676,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 @@ -853,7 +857,9 @@ module ActiveRecord end 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) @@ -874,15 +880,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") @@ -891,7 +896,7 @@ 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 @@ -929,7 +934,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 diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index ae1501f5a1..08cfc3fe5f 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -10,7 +10,7 @@ module ActiveRecord :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, :destroy_by, :delete_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, diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index f021a8f6c4..e0bc5180c0 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -78,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 @@ -128,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 @@ -139,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 @@ -152,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 @@ -222,6 +264,16 @@ 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 @@ -285,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) @@ -308,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( @@ -320,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 @@ -331,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 3452cf971b..1312bf6f91 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 diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 36c2422d84..add95f6a0a 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -291,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. # @@ -389,8 +457,6 @@ module ActiveRecord stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name)) end - stmt.comment(*arel.comment_node.values) if arel.comment_node - @klass.connection.update stmt, "#{@klass} Update All" end @@ -487,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(', ')}") @@ -506,7 +572,6 @@ module ActiveRecord stmt.offset(arel.offset) stmt.order(*arel.orders) stmt.wheres = arel.constraints - stmt.comment(*arel.comment_node.values) if arel.comment_node affected = @klass.connection.delete(stmt, "#{@klass} Destroy") @@ -679,6 +744,10 @@ module ActiveRecord @loaded = true end + def null_relation? # :nodoc: + is_a?(NullRelation) + end + private def already_in_scope? @delegate_to_klass && begin @@ -728,7 +797,7 @@ module ActiveRecord @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 4f9ddf302e..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) @@ -259,10 +260,8 @@ module ActiveRecord 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 @@ -307,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( @@ -344,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) } @@ -370,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) @@ -416,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 7a53a9d1c7..d59331053e 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -45,7 +45,10 @@ module ActiveRecord private def generated_relation_methods - @generated_relation_methods ||= GeneratedRelationMethods.new + @generated_relation_methods ||= GeneratedRelationMethods.new.tap do |mod| + const_set(:GeneratedRelationMethods, mod) + private_constant :GeneratedRelationMethods + end end end diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index e2efd4aa0d..9450e4d3c5 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -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,12 +370,6 @@ 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) relation = except(:includes, :eager_load, :preload).joins!(join_dependency) diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index 4de7465128..6bb77b355c 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -117,16 +117,14 @@ 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) + relation.joins!(join_dependency, *others) end end @@ -136,16 +134,9 @@ 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) + relation.joins!(join_dependency) end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index c37855172b..c03ca7f1e7 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) @@ -246,9 +267,6 @@ 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 @@ -414,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 @@ -986,18 +1005,22 @@ module ActiveRecord @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) # :nodoc: + ActiveRecord::Associations::JoinDependency.new( + klass, table, associations + ) + 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 @@ -1006,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? @@ -1057,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 << construct_join_dependency(left_joins) + end + buckets = joins.group_by do |join| case join when String @@ -1134,9 +1166,9 @@ module ActiveRecord case field when Symbol field = field.to_s - arel_column(field) { connection.quote_table_name(field) } + arel_column(field, &connection.method(:quote_table_name)) when String - arel_column(field) { field } + arel_column(field, &:itself) when Proc field.call else @@ -1146,13 +1178,13 @@ module ActiveRecord end def arel_column(field) - field = klass.attribute_alias(field) if klass.attribute_alias?(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 + yield field end end @@ -1289,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/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index 47728aac30..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 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/named.rb b/activerecord/lib/active_record/scoping/named.rb index 681a5c6250..cd9801b7a0 100644 --- a/activerecord/lib/active_record/scoping/named.rb +++ b/activerecord/lib/active_record/scoping/named.rb @@ -58,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 [] 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 155d2b0b98..c79ed8db60 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -142,6 +142,8 @@ module ActiveRecord end def for_each + return {} unless defined?(Rails) + databases = Rails.application.config.load_database_yaml database_configs = ActiveRecord::DatabaseConfigurations.new(databases).configs_for(env_name: Rails.env) @@ -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 @@ -186,8 +202,8 @@ module ActiveRecord ActiveRecord::Base.connected_to(database: { truncation: configuration }) do table_names = ActiveRecord::Base.connection.tables table_names -= [ - ActiveRecord::Base.schema_migrations_table_name, - ActiveRecord::Base.internal_metadata_table_name + SchemaMigration.table_name, + InternalMetadata.table_name ] ActiveRecord::Base.connection.truncate_tables(*table_names) diff --git a/activerecord/lib/active_record/touch_later.rb b/activerecord/lib/active_record/touch_later.rb index f70b7c50a2..980e42664b 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 @@ -22,7 +22,7 @@ module ActiveRecord @_touch_time = current_time_from_proper_timezone surreptitiously_touch @_defer_touch_attrs - self.class.connection.add_transaction_record self + add_to_transaction # 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..bf781b23eb 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) @@ -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,9 +364,19 @@ module ActiveRecord def with_transaction_returning_status status = nil self.class.transaction do - add_to_transaction + unless has_transactional_callbacks? + 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 + ensure + if has_transactional_callbacks? && + (@_new_record_before_last_commit && !new_record? || _trigger_update_callback || _trigger_destroy_callback) + add_to_transaction + end end status end @@ -382,13 +386,15 @@ module ActiveRecord # 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 + level: 0 + } + @_start_transaction_state[:level] += 1 remember_new_record_before_last_commit end @@ -402,27 +408,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 +454,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 +477,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/arel/nodes/delete_statement.rb b/activerecord/lib/arel/nodes/delete_statement.rb index 56249b2bad..a419975335 100644 --- a/activerecord/lib/arel/nodes/delete_statement.rb +++ b/activerecord/lib/arel/nodes/delete_statement.rb @@ -3,7 +3,7 @@ module Arel # :nodoc: all module Nodes class DeleteStatement < Arel::Nodes::Node - attr_accessor :left, :right, :orders, :limit, :offset, :key, :comment + attr_accessor :left, :right, :orders, :limit, :offset, :key alias :relation :left alias :relation= :left= @@ -18,18 +18,16 @@ module Arel # :nodoc: all @limit = nil @offset = nil @key = nil - @comment = nil end def initialize_copy(other) super @left = @left.clone if @left @right = @right.clone if @right - @comment = @comment.clone if @comment end def hash - [self.class, @left, @right, @orders, @limit, @offset, @key, @comment].hash + [self.class, @left, @right, @orders, @limit, @offset, @key].hash end def eql?(other) @@ -39,8 +37,7 @@ module Arel # :nodoc: all self.orders == other.orders && self.limit == other.limit && self.offset == other.offset && - self.key == other.key && - self.comment == other.comment + self.key == other.key end alias :== :eql? end diff --git a/activerecord/lib/arel/nodes/insert_statement.rb b/activerecord/lib/arel/nodes/insert_statement.rb index 8430dd23da..d28fd1f6c8 100644 --- a/activerecord/lib/arel/nodes/insert_statement.rb +++ b/activerecord/lib/arel/nodes/insert_statement.rb @@ -3,7 +3,7 @@ module Arel # :nodoc: all module Nodes class InsertStatement < Arel::Nodes::Node - attr_accessor :relation, :columns, :values, :select, :comment + attr_accessor :relation, :columns, :values, :select def initialize super() @@ -11,7 +11,6 @@ module Arel # :nodoc: all @columns = [] @values = nil @select = nil - @comment = nil end def initialize_copy(other) @@ -19,11 +18,10 @@ module Arel # :nodoc: all @columns = @columns.clone @values = @values.clone if @values @select = @select.clone if @select - @comment = @comment.clone if @comment end def hash - [@relation, @columns, @values, @select, @comment].hash + [@relation, @columns, @values, @select].hash end def eql?(other) @@ -31,8 +29,7 @@ module Arel # :nodoc: all self.relation == other.relation && self.columns == other.columns && self.select == other.select && - self.values == other.values && - self.comment == other.comment + self.values == other.values end alias :== :eql? end diff --git a/activerecord/lib/arel/nodes/select_core.rb b/activerecord/lib/arel/nodes/select_core.rb index b6154b7ff4..11b4f39ece 100644 --- a/activerecord/lib/arel/nodes/select_core.rb +++ b/activerecord/lib/arel/nodes/select_core.rb @@ -40,7 +40,6 @@ module Arel # :nodoc: all @groups = @groups.clone @havings = @havings.clone @windows = @windows.clone - @comment = @comment.clone if @comment end def hash diff --git a/activerecord/lib/arel/nodes/update_statement.rb b/activerecord/lib/arel/nodes/update_statement.rb index 015bcd7613..cfaa19e392 100644 --- a/activerecord/lib/arel/nodes/update_statement.rb +++ b/activerecord/lib/arel/nodes/update_statement.rb @@ -3,7 +3,7 @@ module Arel # :nodoc: all module Nodes class UpdateStatement < Arel::Nodes::Node - attr_accessor :relation, :wheres, :values, :orders, :limit, :offset, :key, :comment + attr_accessor :relation, :wheres, :values, :orders, :limit, :offset, :key def initialize @relation = nil @@ -13,18 +13,16 @@ module Arel # :nodoc: all @limit = nil @offset = nil @key = nil - @comment = nil end def initialize_copy(other) super @wheres = @wheres.clone @values = @values.clone - @comment = @comment.clone if @comment end def hash - [@relation, @wheres, @values, @orders, @limit, @offset, @key, @comment].hash + [@relation, @wheres, @values, @orders, @limit, @offset, @key].hash end def eql?(other) @@ -35,8 +33,7 @@ module Arel # :nodoc: all self.orders == other.orders && self.limit == other.limit && self.offset == other.offset && - self.key == other.key && - self.comment == other.comment + self.key == other.key end alias :== :eql? end diff --git a/activerecord/lib/arel/select_manager.rb b/activerecord/lib/arel/select_manager.rb index 4e9f527235..ddc9e394dd 100644 --- a/activerecord/lib/arel/select_manager.rb +++ b/activerecord/lib/arel/select_manager.rb @@ -249,10 +249,6 @@ module Arel # :nodoc: all self end - def comment_node - @ctx.comment - end - private def collapse(exprs) exprs = exprs.compact diff --git a/activerecord/lib/arel/tree_manager.rb b/activerecord/lib/arel/tree_manager.rb index 326c4f995c..0476399618 100644 --- a/activerecord/lib/arel/tree_manager.rb +++ b/activerecord/lib/arel/tree_manager.rb @@ -36,11 +36,6 @@ module Arel # :nodoc: all @ast.wheres << expr self end - - def comment(*values) - @ast.comment = Nodes::Comment.new(values) - self - end end attr_reader :ast 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/to_sql.rb b/activerecord/lib/arel/visitors/to_sql.rb index 4192d9efdc..4740e6d94f 100644 --- a/activerecord/lib/arel/visitors/to_sql.rb +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -35,7 +35,6 @@ module Arel # :nodoc: all collect_nodes_for o.wheres, collector, " WHERE ", " AND " collect_nodes_for o.orders, collector, " ORDER BY " maybe_visit o.limit, collector - maybe_visit o.comment, collector end def visit_Arel_Nodes_UpdateStatement(o, collector) @@ -48,7 +47,6 @@ module Arel # :nodoc: all collect_nodes_for o.wheres, collector, " WHERE ", " AND " collect_nodes_for o.orders, collector, " ORDER BY " maybe_visit o.limit, collector - maybe_visit o.comment, collector end def visit_Arel_Nodes_InsertStatement(o, collector) @@ -64,9 +62,9 @@ module Arel # :nodoc: all maybe_visit o.values, collector elsif o.select maybe_visit o.select, collector + else + collector end - - maybe_visit o.comment, collector end def visit_Arel_Nodes_Exists(o, collector) @@ -515,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 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 cdd029735a..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,11 +1,17 @@ <% 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 %> <% end -%> diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 2b20d842e8..ce2ed06c1d 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -485,23 +485,57 @@ module ActiveRecord end def test_truncate - assert_operator @connection.query_value("SELECT COUNT(*) FROM posts"), :>, 0 + assert_operator Post.count, :>, 0 @connection.truncate("posts") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM 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 @connection.query_value("SELECT COUNT(*) FROM posts"), :>, 0 - assert_operator @connection.query_value("SELECT COUNT(*) FROM authors"), :>, 0 - assert_operator @connection.query_value("SELECT COUNT(*) FROM author_addresses"), :>, 0 + 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, @connection.query_value("SELECT COUNT(*) FROM posts") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM authors") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM author_addresses") + 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 @@ -523,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/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/optimizer_hints_test.rb b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb index b9794c5710..628802b216 100644 --- a/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb +++ b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb @@ -15,6 +15,14 @@ if supports_optimizer_hints? 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) */") 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/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/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 cd45975f70..671d8211a7 100644 --- a/activerecord/test/cases/adapters/postgresql/hstore_test.rb +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -153,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" diff --git a/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb index 5e4bf232e1..5b9f5e0832 100644 --- a/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb +++ b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb @@ -19,6 +19,14 @@ if supports_optimizer_hints? 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) */") 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/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/delete_manager_test.rb b/activerecord/test/cases/arel/delete_manager_test.rb index 63cd1bffe3..0bad02f4d2 100644 --- a/activerecord/test/cases/arel/delete_manager_test.rb +++ b/activerecord/test/cases/arel/delete_manager_test.rb @@ -49,23 +49,5 @@ module Arel dm.where(table[:id].eq(10)).must_equal dm end end - - describe "comment" do - it "chains" do - manager = Arel::DeleteManager.new - manager.comment("deleting").must_equal manager - end - - it "appends a comment to the generated query" do - table = Table.new(:users) - dm = Arel::DeleteManager.new - dm.from table - dm.comment("deletion") - assert_match(%r{DELETE FROM "users" /\* deletion \*/}, dm.to_sql) - - dm.comment("deletion", "with", "comment") - assert_match(%r{DELETE FROM "users" /\* deletion \*/ /\* with \*/ /\* comment \*/}, dm.to_sql) - end - end end end diff --git a/activerecord/test/cases/arel/nodes/delete_statement_test.rb b/activerecord/test/cases/arel/nodes/delete_statement_test.rb index 8ba268653d..3f078063a4 100644 --- a/activerecord/test/cases/arel/nodes/delete_statement_test.rb +++ b/activerecord/test/cases/arel/nodes/delete_statement_test.rb @@ -18,10 +18,8 @@ describe Arel::Nodes::DeleteStatement do it "is equal with equal ivars" do statement1 = Arel::Nodes::DeleteStatement.new statement1.wheres = %w[a b c] - statement1.comment = Arel::Nodes::Comment.new(["comment"]) statement2 = Arel::Nodes::DeleteStatement.new statement2.wheres = %w[a b c] - statement2.comment = Arel::Nodes::Comment.new(["comment"]) array = [statement1, statement2] assert_equal 1, array.uniq.size end @@ -29,14 +27,8 @@ describe Arel::Nodes::DeleteStatement do it "is not equal with different ivars" do statement1 = Arel::Nodes::DeleteStatement.new statement1.wheres = %w[a b c] - statement1.comment = Arel::Nodes::Comment.new(["comment"]) statement2 = Arel::Nodes::DeleteStatement.new statement2.wheres = %w[1 2 3] - statement2.comment = Arel::Nodes::Comment.new(["comment"]) - array = [statement1, statement2] - assert_equal 2, array.uniq.size - statement2.wheres = %w[a b c] - statement2.comment = Arel::Nodes::Comment.new(["other"]) array = [statement1, statement2] assert_equal 2, array.uniq.size end diff --git a/activerecord/test/cases/arel/nodes/insert_statement_test.rb b/activerecord/test/cases/arel/nodes/insert_statement_test.rb index 036576b231..252a0d0d0b 100644 --- a/activerecord/test/cases/arel/nodes/insert_statement_test.rb +++ b/activerecord/test/cases/arel/nodes/insert_statement_test.rb @@ -23,11 +23,9 @@ describe Arel::Nodes::InsertStatement do statement1 = Arel::Nodes::InsertStatement.new statement1.columns = %w[a b c] statement1.values = %w[x y z] - statement1.comment = Arel::Nodes::Comment.new(["comment"]) statement2 = Arel::Nodes::InsertStatement.new statement2.columns = %w[a b c] statement2.values = %w[x y z] - statement2.comment = Arel::Nodes::Comment.new(["comment"]) array = [statement1, statement2] assert_equal 1, array.uniq.size end @@ -36,15 +34,9 @@ describe Arel::Nodes::InsertStatement do statement1 = Arel::Nodes::InsertStatement.new statement1.columns = %w[a b c] statement1.values = %w[x y z] - statement1.comment = Arel::Nodes::Comment.new(["comment"]) statement2 = Arel::Nodes::InsertStatement.new statement2.columns = %w[a b c] statement2.values = %w[1 2 3] - statement2.comment = Arel::Nodes::Comment.new(["comment"]) - array = [statement1, statement2] - assert_equal 2, array.uniq.size - statement2.values = %w[x y z] - statement2.comment = Arel::Nodes::Comment.new("other") array = [statement1, statement2] assert_equal 2, array.uniq.size end diff --git a/activerecord/test/cases/arel/nodes/update_statement_test.rb b/activerecord/test/cases/arel/nodes/update_statement_test.rb index f133ddf7eb..a83ce32f68 100644 --- a/activerecord/test/cases/arel/nodes/update_statement_test.rb +++ b/activerecord/test/cases/arel/nodes/update_statement_test.rb @@ -27,7 +27,6 @@ describe Arel::Nodes::UpdateStatement do statement1.orders = %w[x y z] statement1.limit = 42 statement1.key = "zomg" - statement1.comment = Arel::Nodes::Comment.new(["comment"]) statement2 = Arel::Nodes::UpdateStatement.new statement2.relation = "zomg" statement2.wheres = 2 @@ -35,7 +34,6 @@ describe Arel::Nodes::UpdateStatement do statement2.orders = %w[x y z] statement2.limit = 42 statement2.key = "zomg" - statement2.comment = Arel::Nodes::Comment.new(["comment"]) array = [statement1, statement2] assert_equal 1, array.uniq.size end @@ -48,7 +46,6 @@ describe Arel::Nodes::UpdateStatement do statement1.orders = %w[x y z] statement1.limit = 42 statement1.key = "zomg" - statement1.comment = Arel::Nodes::Comment.new(["comment"]) statement2 = Arel::Nodes::UpdateStatement.new statement2.relation = "zomg" statement2.wheres = 2 @@ -56,11 +53,6 @@ describe Arel::Nodes::UpdateStatement do statement2.orders = %w[x y z] statement2.limit = 42 statement2.key = "wth" - statement2.comment = Arel::Nodes::Comment.new(["comment"]) - array = [statement1, statement2] - assert_equal 2, array.uniq.size - statement2.key = "zomg" - statement2.comment = Arel::Nodes::Comment.new(["other"]) array = [statement1, statement2] assert_equal 2, array.uniq.size end diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb index 18e6c10c9d..5ebeabd4a3 100644 --- a/activerecord/test/cases/arel/support/fake_record.rb +++ b/activerecord/test/cases/arel/support/fake_record.rb @@ -62,6 +62,10 @@ module FakeRecord comment end + def in_clause_length + 3 + end + def schema_cache self end diff --git a/activerecord/test/cases/arel/update_manager_test.rb b/activerecord/test/cases/arel/update_manager_test.rb index e13cb6aa52..cc1b9ac5b3 100644 --- a/activerecord/test/cases/arel/update_manager_test.rb +++ b/activerecord/test/cases/arel/update_manager_test.rb @@ -122,29 +122,5 @@ module Arel @um.key.must_equal @table[:foo] end end - - describe "comment" do - it "chains" do - manager = Arel::UpdateManager.new - manager.comment("updating").must_equal manager - end - - it "appends a comment to the generated query" do - table = Table.new :users - - manager = Arel::UpdateManager.new - manager.table table - - manager.comment("updating") - manager.to_sql.must_be_like %{ - UPDATE "users" /* updating */ - } - - manager.comment("updating", "with", "comment") - manager.to_sql.must_be_like %{ - UPDATE "users" /* updating */ /* with */ /* comment */ - } - end - end end end diff --git a/activerecord/test/cases/arel/visitors/to_sql_test.rb b/activerecord/test/cases/arel/visitors/to_sql_test.rb index 625e37f1c0..fd19574876 100644 --- a/activerecord/test/cases/arel/visitors/to_sql_test.rb +++ b/activerecord/test/cases/arel/visitors/to_sql_test.rb @@ -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/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 cd9c8a5285..b865002f03 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -240,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 @@ -249,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 @@ -1400,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 diff --git a/activerecord/test/cases/associations/extension_test.rb b/activerecord/test/cases/associations/extension_test.rb index aef8f31112..d93d787f7c 100644 --- a/activerecord/test/cases/associations/extension_test.rb +++ b/activerecord/test/cases/associations/extension_test.rb @@ -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 6a7efe2121..32285f269a 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -2578,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 620c89ddca..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, @@ -1496,6 +1499,19 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase 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..0a8863c35d 100644 --- a/activerecord/test/cases/associations/left_outer_join_association_test.rb +++ b/activerecord/test/cases/associations/left_outer_join_association_test.rb @@ -46,6 +46,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 +66,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/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index 9fd62dcf72..5cbe5d796d 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -1081,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(Topic::GeneratedAttributeMethods), mod.inspect + assert_equal "Topic::GeneratedAttributeMethods", mod.inspect end private diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index 866818b2ab..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[/[^:]*$/] @@ -1035,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 @@ -1210,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 diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index f1e35d6ab9..16c2a3661d 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -243,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 } @@ -357,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) 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/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index 5113548091..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 @@ -28,6 +29,7 @@ module ActiveRecord 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 @@ -55,6 +57,20 @@ module ActiveRecord @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 assert_nil @cache.primary_keys("omgponies") end @@ -74,6 +90,18 @@ module ActiveRecord 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") @@ -84,6 +112,7 @@ module ActiveRecord @cache.clear! assert_equal 0, @cache.size + assert_nil @cache.instance_variable_get(:@database_version) end def test_dump_and_load @@ -101,6 +130,7 @@ module ActiveRecord 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 diff --git a/activerecord/test/cases/date_time_precision_test.rb b/activerecord/test/cases/date_time_precision_test.rb index 9d1af9362d..79d63949ca 100644 --- a/activerecord/test/cases/date_time_precision_test.rb +++ b/activerecord/test/cases/date_time_precision_test.rb @@ -82,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/helper.rb b/activerecord/test/cases/helper.rb index f95d082907..543a0aeb39 100644 --- a/activerecord/test/cases/helper.rb +++ b/activerecord/test/cases/helper.rb @@ -53,7 +53,7 @@ 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 @@ -202,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/insert_all_test.rb b/activerecord/test/cases/insert_all_test.rb index 0818d7c1ab..f24c63031c 100644 --- a/activerecord/test/cases/insert_all_test.rb +++ b/activerecord/test/cases/insert_all_test.rb @@ -11,6 +11,20 @@ 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 @@ -90,6 +104,44 @@ class InsertAllTest < ActiveRecord::TestCase 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? @@ -129,6 +181,42 @@ class InsertAllTest < ActiveRecord::TestCase 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? @@ -172,4 +260,17 @@ class InsertAllTest < ActiveRecord::TestCase 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/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb index d68cc40107..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,6 +329,38 @@ 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 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/column_attributes_test.rb b/activerecord/test/cases/migration/column_attributes_test.rb index 6f9190c110..b6064500ee 100644 --- a/activerecord/test/cases/migration/column_attributes_test.rb +++ b/activerecord/test/cases/migration/column_attributes_test.rb @@ -176,9 +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 } - assert_raise(ActiveRecordError) { add_column :test_models, :text_too_big, :text, limit: 0xfffffffff } - assert_raise(ActiveRecordError) { add_column :test_models, :binary_too_big, :binary, limit: 0xfffffffff } + 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 dbbba9c5fa..cce3461e18 100644 --- a/activerecord/test/cases/migration/columns_test.rb +++ b/activerecord/test/cases/migration/columns_test.rb @@ -136,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 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 ba21923d79..5f1057f093 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -155,7 +155,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? class ForeignKeyChangeColumnWithSuffixTest < ForeignKeyChangeColumnTest setup do - ActiveRecord::Base.table_name_suffix = "_p" + ActiveRecord::Base.table_name_suffix = "_s" end teardown do diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index 788c8c36b8..8e8ed494d9 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 @@ -581,7 +583,7 @@ class MigrationTest < ActiveRecord::TestCase 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 @@ -593,7 +595,7 @@ class MigrationTest < ActiveRecord::TestCase end 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 @@ -605,15 +607,15 @@ class MigrationTest < ActiveRecord::TestCase end def test_out_of_range_binary_limit_should_raise - e = assert_raise(ActiveRecord::ActiveRecordError) do - Person.connection.create_table :test_text_limits, force: true do |t| + 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_text_limits, if_exists: true + Person.connection.drop_table :test_binary_limits, if_exists: true end 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 172fa20bc3..085006c9a2 100644 --- a/activerecord/test/cases/relation/delegation_test.rb +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -51,12 +51,12 @@ module ActiveRecord 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] + [ + } - [: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, :delete_by, :destroy_by + :destroy_all, :delete_all, :update_all, :touch_all, :delete_by, :destroy_by ] def test_delegate_querying_methods diff --git a/activerecord/test/cases/relation/delete_all_test.rb b/activerecord/test/cases/relation/delete_all_test.rb index 9b76936b7e..d1c13fa1b5 100644 --- a/activerecord/test/cases/relation/delete_all_test.rb +++ b/activerecord/test/cases/relation/delete_all_test.rb @@ -99,23 +99,4 @@ class DeleteAllTest < ActiveRecord::TestCase assert_raise(ActiveRecord::RecordNotFound) { posts(:thinking) } assert posts(:welcome) end - - def test_delete_all_with_annotation_includes_a_query_comment - davids = Author.where(name: "David").annotate("deleting all") - - assert_sql(%r{/\* deleting all \*/}) do - assert_difference("Author.count", -1) { davids.delete_all } - end - end - - def test_delete_all_without_annotation_does_not_include_an_empty_comment - davids = Author.where(name: "David") - - log = capture_sql do - assert_difference("Author.count", -1) { davids.delete_all } - end - - assert_not_predicate log, :empty? - assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? - end end diff --git a/activerecord/test/cases/relation/update_all_test.rb b/activerecord/test/cases/relation/update_all_test.rb index 526f926841..e45531b4a9 100644 --- a/activerecord/test/cases/relation/update_all_test.rb +++ b/activerecord/test/cases/relation/update_all_test.rb @@ -241,31 +241,36 @@ class UpdateAllTest < ActiveRecord::TestCase end end - def test_update_all_with_annotation_includes_a_query_comment - tag = Tag.first + def test_klass_level_update_all + travel 5.seconds do + now = Time.now.utc - assert_sql(%r{/\* updating all \*/}) do - Post.tagged_with(tag.id).annotate("updating all").update_all(title: "rofl") - end + Person.all.each do |person| + assert_not_equal now, person.updated_at + end - posts = Post.tagged_with(tag.id).all.to_a - assert_operator posts.length, :>, 0 - posts.each { |post| assert_equal "rofl", post.title } + Person.update_all(updated_at: now) + + Person.all.each do |person| + assert_equal now, person.updated_at + end + end end - def test_update_all_without_annotation_does_not_include_an_empty_comment - tag = Tag.first + def test_klass_level_touch_all + travel 5.seconds do + now = Time.now.utc - log = capture_sql do - Post.tagged_with(tag.id).update_all(title: "rofl") - end + Person.all.each do |person| + assert_not_equal now, person.updated_at + end - assert_not_predicate log, :empty? - assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + Person.touch_all(time: now) - posts = Post.tagged_with(tag.id).all.to_a - assert_operator posts.length, :>, 0 - posts.each { |post| assert_equal "rofl", post.title } + Person.all.each do |person| + assert_equal now, person.updated_at + end + end end # Oracle UPDATE does not support ORDER BY 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 b045184d7d..6c1e3e7fec 100644 --- a/activerecord/test/cases/relation/where_test.rb +++ b/activerecord/test/cases/relation/where_test.rb @@ -20,6 +20,13 @@ 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") @@ -115,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 diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 00a7b3841f..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 diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 131e034c66..2417775ef1 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -985,6 +985,12 @@ 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 } diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb index a95ab0f429..50b514d464 100644 --- a/activerecord/test/cases/scoping/relation_scoping_test.rb +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -411,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 @@ -452,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/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 06f11108f9..ffe94eee0f 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -50,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, @@ -73,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, @@ -755,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" @@ -767,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 @@ -951,11 +958,22 @@ module ActiveRecord 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 @@ -969,12 +987,46 @@ module ActiveRecord ) 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 diff --git a/activerecord/test/cases/time_precision_test.rb b/activerecord/test/cases/time_precision_test.rb index 2f534ea110..1abd857216 100644 --- a/activerecord/test/cases/time_precision_test.rb +++ b/activerecord/test/cases/time_precision_test.rb @@ -75,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..53fe31e087 100644 --- a/activerecord/test/cases/transaction_callbacks_test.rb +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -38,6 +38,7 @@ class TransactionCallbacksTest < ActiveRecord::TestCase 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 +111,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 @@ -586,7 +624,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 +634,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 +655,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/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/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/post.rb b/activerecord/test/models/post.rb index 61e5f14100..090b576a5d 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 @@ -203,6 +207,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" @@ -315,8 +323,8 @@ class FakeKlass "posts" end - def attribute_alias?(name) - false + def attribute_aliases + {} end def sanitize_sql(sql) 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/schema/schema.rb b/activerecord/test/schema/schema.rb index ead4de2a13..41920b3719 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -19,6 +19,7 @@ ActiveRecord::Schema.define do 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| @@ -523,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| @@ -791,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 |