diff options
Diffstat (limited to 'activerecord')
878 files changed, 32369 insertions, 12561 deletions
diff --git a/activerecord/.gitignore b/activerecord/.gitignore new file mode 100644 index 0000000000..884ee009eb --- /dev/null +++ b/activerecord/.gitignore @@ -0,0 +1,3 @@ +/sqlnet.log +/test/config.yml +/test/fixtures/*.sqlite* diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 55773f2172..336946b756 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,709 +1,173 @@ -* Use `max_identifier_length` for `index_name_length` in PostgreSQL adapter. +* Use MySQL utf8mb4 character set by default. - *Ryuta Kamizono* - -* Deprecate `supports_migrations?` on connection adapters. - - *Ryuta Kamizono* - -* Fix regression of #1969 with SELECT aliases in HAVING clause. - - *Eugene Kenny* - -* Deprecate using `#quoted_id` in quoting. - - *Ryuta Kamizono* - -* Fix `wait_timeout` to configurable for mysql2 adapter. - - Fixes #26556. - - *Ryuta Kamizono* - - -## Rails 5.1.0.beta1 (February 23, 2017) ## - -* Correctly dump native timestamp types for MySQL. + `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. - The native timestamp type in MySQL is different from datetime type. - Internal representation of the timestamp type is UNIX time, This means - that timestamp columns are affected by time zone. + *Yasuo Honda* - > SET time_zone = '+00:00'; - Query OK, 0 rows affected (0.00 sec) +* Fix duplicated record creation when using nested attributes with `create_with`. - > INSERT INTO time_with_zone(ts,dt) VALUES (NOW(),NOW()); - Query OK, 1 row affected (0.02 sec) + *Darwin Wu* - > SELECT * FROM time_with_zone; - +---------------------+---------------------+ - | ts | dt | - +---------------------+---------------------+ - | 2016-02-07 22:11:44 | 2016-02-07 22:11:44 | - +---------------------+---------------------+ - 1 row in set (0.00 sec) +* Configuration item `config.filter_parameters` could also filter out sensitive value of database column when call `#inspect`. - > SET time_zone = '-08:00'; - Query OK, 0 rows affected (0.00 sec) + ``` + Rails.application.config.filter_parameters += [:credit_card_number] + Account.last.inspect # => #<Account id: 123, credit_card_number: [FILTERED] ...> + ``` - > SELECT * FROM time_with_zone; - +---------------------+---------------------+ - | ts | dt | - +---------------------+---------------------+ - | 2016-02-07 14:11:44 | 2016-02-07 22:11:44 | - +---------------------+---------------------+ - 1 row in set (0.00 sec) + *Zhang Kang* - *Ryuta Kamizono* - -* All integer-like PKs are autoincrement unless they have an explicit default. - - *Matthew Draper* - -* Omit redundant `using: :btree` for schema dumping. - - *Ryuta Kamizono* - -* Deprecate passing `default` to `index_name_exists?`. - - *Ryuta Kamizono* - -* PostgreSQL: schema dumping support for interval and OID columns. - - *Ryuta Kamizono* - -* Deprecate `supports_primary_key?` on connection adapters since it's - been long unused and unsupported. +* 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* -* Make `table_name=` reset current statement cache, - so queries are not run against the previous table name. - - *namusyaka* +* `ActiveRecord::Base.configurations` now returns an object. -* Allow `ActiveRecord::Base#as_json` to be passed a frozen Hash. + `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. - *Isaac Betesh* + For example, the following `database.yml`: -* Fix inspection behavior when the :id column is not primary key. + ``` + development: + adapter: sqlite3 + database: db/development.sqlite3 + ``` - *namusyaka* + Used to become a hash: -* Deprecate locking records with unpersisted changes. + ``` + { "development" => { "adapter" => "sqlite3", "database" => "db/development.sqlite3" } } + ``` - *Marc Schütz* + Is now converted into the following object: -* Remove deprecated behavior that halts callbacks when the return is false. + ``` + #<ActiveRecord::DatabaseConfigurations:0x00007fd1acbdf800 @configurations=[ + #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 @env_name="development", + @spec_name="primary", @config={"adapter"=>"sqlite3", "database"=>"db/development.sqlite3"}> + ] + ``` - *Rafael Mendonça França* + 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. -* Deprecate `ColumnDumper#migration_keys`. + ``` + ActiveRecord::Base.configurations.configs_for(env_name: "development") + ActiveRecord::Base.configurations.configs_for(env_name: "development", spec_name: "primary") + ``` - *Ryuta Kamizono* + *Eileen M. Uchitelle*, *Aaron Patterson* -* Fix `association_primary_key_type` for reflections with symbol primary key. +* Add database configuration to disable advisory locks. - Fixes #27864. + ``` + production: + adapter: postgresql + advisory_locks: false + ``` - *Daniel Colson* + *Guo Xiang* -* Virtual/generated column support for MySQL 5.7.5+ and MariaDB 5.2.0+. +* SQLite3 adapter `alter_table` method restores foreign keys. - MySQL generated columns: https://dev.mysql.com/doc/refman/5.7/en/create-table-generated-columns.html - MariaDB virtual columns: https://mariadb.com/kb/en/mariadb/virtual-computed-columns/ + *Yasuo Honda* - Declare virtual columns with `t.virtual name, type: …, as: "expression"`. - Pass `stored: true` to persist the generated value (false by default). +* Allow `:to_table` option to `invert_remove_foreign_key`. Example: - create_table :generated_columns do |t| - t.string :name - t.virtual :upper_name, type: :string, as: "UPPER(name)" - t.virtual :name_length, type: :integer, as: "LENGTH(name)", stored: true - t.index :name_length # May be indexed, too! - end - - *Ryuta Kamizono* - -* Deprecate `initialize_schema_migrations_table` and `initialize_internal_metadata_table`. - - *Ryuta Kamizono* - -* Support foreign key creation for SQLite3. - - *Ryuta Kamizono* - -* Place generated migrations into the path set by `config.paths["db/migrate"]`. - - *Kevin Glowacz* - -* Raise `ActiveRecord::InvalidForeignKey` when a foreign key constraint fails on SQLite3. - - *Ryuta Kamizono* - -* Add the touch option to `#increment!` and `#decrement!`. - - *Hiroaki Izu* - -* Deprecate passing a class to the `class_name` because it eagerloads more classes than - necessary and potentially creates circular dependencies. - - *Kir Shatrov* - -* Raise error when has_many through is defined before through association. - - Fixes #26834. - - *Chris Holmes* - -* Deprecate passing `name` to `indexes`. - - *Ryuta Kamizono* - -* Remove deprecated tasks: `db:test:clone`, `db:test:clone_schema`, `db:test:clone_structure`. - - *Rafel Mendonça França* - -* Compare deserialized values for `PostgreSQL::OID::Hstore` types when - calling `ActiveRecord::Dirty#changed_in_place?`. - - Fixes #27502. - - *Jon Moss* - -* Raise `ArgumentError` when passing an `ActiveRecord::Base` instance to `.find`, - `.exists?` and `.update`. - - *Rafael Mendonça França* - -* Respect precision option for arrays of timestamps. - - Fixes #27514. - - *Sean Griffin* - -* Optimize slow model instantiation when using STI and `store_full_sti_class = false` option. - - *Konstantin Lazarev* - -* Add `touch` option to counter cache modifying methods. - - Works when updating, resetting, incrementing and decrementing counters: - - # Touches `updated_at`/`updated_on`. - Topic.increment_counter(:messages_count, 1, touch: true) - Topic.decrement_counter(:messages_count, 1, touch: true) - - # Touches `last_discussed_at`. - Topic.reset_counters(18, :messages, touch: :last_discussed_at) - - # Touches `updated_at` and `last_discussed_at`. - Topic.update_counters(18, messages_count: 5, touch: %i( updated_at last_discussed_at )) - - Fixes #26724. - - *Jarred Trost* - -* Remove deprecated `#uniq`, `#uniq!`, and `#uniq_value`. - - *Ryuta Kamizono* - -* Remove deprecated `#insert_sql`, `#update_sql`, and `#delete_sql`. - - *Ryuta Kamizono* - -* Remove deprecated `#use_transactional_fixtures` configuration. - - *Rafael Mendonça França* - -* Remove deprecated `#raise_in_transactional_callbacks` configuration. - - *Rafael Mendonça França* - -* Remove deprecated `#load_schema_for`. - - *Rafael Mendonça França* - -* Remove deprecated conditions parameter from `#destroy_all` and `#delete_all`. - - *Rafael Mendonça França* - -* Remove deprecated support to passing arguments to `#select` when a block is provided. - - *Rafael Mendonça França* - -* Remove deprecated support to query using commas on LIMIT. - - *Rafael Mendonça França* - -* Remove deprecated support to passing a class as a value in a query. - - *Rafael Mendonça França* - -* Raise `ActiveRecord::IrreversibleOrderError` when using `last` with an irreversible - order. - - *Rafael Mendonça França* - -* Raise when a `has_many :through` association has an ambiguous reflection name. - - *Rafael Mendonça França* - -* Raise when `ActiveRecord::Migration` is inherited from directly. - - *Rafael Mendonça França* - -* Remove deprecated `original_exception` argument in `ActiveRecord::StatementInvalid#initialize` - and `ActiveRecord::StatementInvalid#original_exception`. - - *Rafael Mendonça França* - -* `#tables` and `#table_exists?` return only tables and not views. - - All the deprecations on those methods were removed. - - *Rafael Mendonça França* - -* Remove deprecated `name` argument from `#tables`. - - *Rafael Mendonça França* - -* Remove deprecated support to passing a column to `#quote`. - - *Rafael Mendonça França* - -* Set `:time` as a timezone aware type and remove deprecation when - `config.active_record.time_zone_aware_types` is not explicitly set. - - *Rafael Mendonça França* - -* Remove deprecated force reload argument in singular and collection association readers. - - *Rafael Mendonça França* - -* Remove deprecated `activerecord.errors.messages.restrict_dependent_destroy.one` and - `activerecord.errors.messages.restrict_dependent_destroy.many` i18n scopes. - - *Rafael Mendonça França* - -* Allow passing extra flags to `db:structure:load` and `db:structure:dump` - - Introduces `ActiveRecord::Tasks::DatabaseTasks.structure_(load|dump)_flags` to customize the - eventual commands run against the database, e.g. mysqldump/pg_dump. - - *Kir Shatrov* - -* Notifications see frozen SQL string. - - Fixes #23774. - - *Richard Monette* - -* RuntimeErrors are no longer translated to `ActiveRecord::StatementInvalid`. - - *Richard Monette* - -* Change the schema cache format to use YAML instead of Marshal. - - *Kir Shatrov* - -* Support index length and order options using both string and symbol - column names. - - Fixes #27243. + remove_foreign_key :accounts, to_table: :owners - *Ryuta Kamizono* - -* Raise `ActiveRecord::RangeError` when values that executed are out of range. - - *Ryuta Kamizono* - -* Raise `ActiveRecord::NotNullViolation` when a record cannot be inserted - or updated because it would violate a not null constraint. - - *Ryuta Kamizono* - -* Emulate db trigger behaviour for after_commit :destroy, :update. - - Race conditions can occur when an ActiveRecord is destroyed - twice or destroyed and updated. The callbacks should only be - triggered once, similar to a SQL database trigger. - - *Stefan Budeanu* - -* Moved `DecimalWithoutScale`, `Text`, and `UnsignedInteger` from Active Model to Active Record. - - *Iain Beeston* - -* Fix `write_attribute` method to check whether an attribute is aliased or not, and - use the aliased attribute name if needed. - - *Prathamesh Sonpatki* - -* Fix `read_attribute` method to check whether an attribute is aliased or not, and - use the aliased attribute name if needed. - - Fixes #26417. + *Nikolay Epifanov*, *Rich Chen* - *Prathamesh Sonpatki* +* Add environment & load_config dependency to `bin/rake db:seed` to enable + seed load in environments without Rails and custom DB configuration -* PostgreSQL & MySQL: Use big integer as primary key type for new tables. + *Tobias Bielohlawek* - *Jon McCartie*, *Pavel Pravosud* +* Fix default value for mysql time types with specified precision. -* Change the type argument of `ActiveRecord::Base#attribute` to be optional. - The default is now `ActiveRecord::Type::Value.new`, which provides no type - casting behavior. + *Nikolay Kondratyev* - *Sean Griffin* - -* Don't treat unsigned integers with zerofill as signed. - - Fixes #27125. +* Fix `touch` option to behave consistently with `Persistence#touch` method. *Ryuta Kamizono* -* Fix the uniqueness validation scope with a polymorphic association. - - *Sergey Alekseev* - -* Raise `ActiveRecord::RecordNotFound` from collection `*_ids` setters - for unknown IDs with a better error message. +* Migrations raise when duplicate column definition. - Changes the collection `*_ids` setters to cast provided IDs the data - type of the primary key set in the association, not the model - primary key. + Fixes #33024. - *Dominic Cleal* + *Federico Martinez* -* For PostgreSQL >= 9.4 use `pgcrypto`'s `gen_random_uuid()` instead of - `uuid-ossp`'s UUID generation function. +* Bump minimum SQLite version to 3.8 - *Yuji Yaginuma*, *Yaw Boakye* + *Yasuo Honda* -* Introduce `Model#reload_<association>` to bring back the behavior - of `Article.category(true)` where `category` is a singular - association. +* Fix parent record should not get saved with duplicate children records. - The force reloading of the association reader was deprecated - in #20888. Unfortunately the suggested alternative of - `article.reload.category` does not expose the same behavior. + Fixes #32940. - This patch adds a reader method with the prefix `reload_` for - singular associations. This method has the same semantics as - passing true to the association reader used to have. + *Santosh Wadghule* - *Yves Senn* +* Fix logic on disabling commit callbacks so they are not called unexpectedly when errors occur. -* Make sure eager loading `ActiveRecord::Associations` also loads - constants defined in `ActiveRecord::Associations::Preloader`. + *Brian Durand* - *Yves Senn* +* Ensure `Associations::CollectionAssociation#size` and `Associations::CollectionAssociation#empty?` + use loaded association ids if present. -* Allow `ActionController::Parameters`-like objects to be passed as - values for Postgres HStore columns. + *Graham Turner* - Fixes #26904. +* Add support to preload associations of polymorphic associations when not all the records have the requested associations. - *Jon Moss* + *Dana Sherson* -* Added `stat` method to `ActiveRecord::ConnectionAdapters::ConnectionPool`. +* Add `touch_all` method to `ActiveRecord::Relation`. Example: - ActiveRecord::Base.connection_pool.stat # => - { size: 15, connections: 1, busy: 1, dead: 0, idle: 0, waiting: 0, checkout_timeout: 5 } - - *Pavel Evstigneev* - -* Avoid `unscope(:order)` when `limit_value` is presented for `count` - and `exists?`. - - If `limit_value` is presented, records fetching order is very important - for performance. We should not unscope the order in the case. - - *Ryuta Kamizono* - -* Fix an Active Record `DateTime` field `NoMethodError` caused by incomplete - datetime. - - Fixes #24195. - - *Sen Zhang* - -* Allow `slice` to take an array of methods(without the need for splatting). - - *Cohen Carlisle* - -* Improved partial writes with HABTM and has many through associations - to fire database query only if relation has been changed. - - Fixes #19663. - - *Mehmet Emin İNAÇ* - -* Deprecate passing arguments and block at the same time to - `ActiveRecord::QueryMethods#select`. - - *Prathamesh Sonpatki* - -* Optimistic locking: Added ability to update `locking_column` value. - Ignore optimistic locking if trying to update with new `locking_column` value. - - *bogdanvlviv* - -* Fixed: Optimistic locking does not work well with `null` in the database. - - Fixes #26024. - - *bogdanvlviv* - -* Fixed support for case insensitive comparisons of `text` columns in - PostgreSQL. - - *Edho Arief* - -* Serialize JSON attribute value `nil` as SQL `NULL`, not JSON `null`. - - *Trung Duc Tran* - -* Return `true` from `update_attribute` when the value of the attribute - to be updated is unchanged. - - Fixes #26593. - - *Prathamesh Sonpatki* - -* Always store errors details information with symbols. - - When the association is autosaved we were storing the details with - string keys. This was creating inconsistency with other details that are - added using the `Errors#add` method. It was also inconsistent with the - `Errors#messages` storage. - - To fix this inconsistency we are always storing with symbols. This will - cause a small breaking change because in those cases the details could - be accessed as strings keys but now it can not. - - Fix #26499. - - *Rafael Mendonça França*, *Marcus Vieira* - -* Calling `touch` on a model using optimistic locking will now leave the model - in a non-dirty state with no attribute changes. - - Fixes #26496. - - *Jakob Skjerning* - -* Using a mysql2 connection after it fails to reconnect will now have an error message - saying the connection is closed rather than an undefined method error message. - - *Dylan Thacker-Smith* - -* PostgreSQL array columns will now respect the encoding of strings contained - in the array. - - Fixes #26326. - - *Sean Griffin* - -* Inverse association instances will now be set before `after_find` or - `after_initialize` callbacks are run. - - Fixes #26320. - - *Sean Griffin* + Person.where(name: "David").touch_all(time: Time.new(2020, 5, 16, 0, 0, 0)) -* Remove unnecessarily association load when a `belongs_to` association has already been - loaded then the foreign key is changed directly and the record saved. + *fatkodima*, *duggiefresh* - *James Coleman* +* Add `ActiveRecord::Base.base_class?` predicate. -* Remove standardized column types/arguments spaces in schema dump. + *Bogdan Gusiev* - *Tim Petricola* +* Add custom prefix/suffix options to `ActiveRecord::Store.store_accessor`. -* Avoid loading records from database when they are already loaded using - the `pluck` method on a collection. + *Tan Huynh*, *Yukio Mizuta* - Fixes #25921. +* Rails 6 requires Ruby 2.4.1 or newer. - *Ryuta Kamizono* - -* Remove text default treated as an empty string in non-strict mode for - consistency with other types. - - Strict mode controls how MySQL handles invalid or missing values in - data-change statements such as INSERT or UPDATE. If strict mode is not - in effect, MySQL inserts adjusted values for invalid or missing values - and produces warnings. - - def test_mysql_not_null_defaults_non_strict - using_strict(false) do - with_mysql_not_null_table do |klass| - record = klass.new - assert_nil record.non_null_integer - assert_nil record.non_null_string - assert_nil record.non_null_text - assert_nil record.non_null_blob - - record.save! - record.reload - - assert_equal 0, record.non_null_integer - assert_equal "", record.non_null_string - assert_equal "", record.non_null_text - assert_equal "", record.non_null_blob - end - end - end - - https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sql-mode-strict - - *Ryuta Kamizono* - -* SQLite3 migrations to add a column to an existing table can now be - successfully rolled back when the column was given and invalid column - type. - - Fixes #26087. - - *Travis O'Neill* - -* Deprecate `sanitize_conditions`. Use `sanitize_sql` instead. - - *Ryuta Kamizono* - -* Doing count on relations that contain LEFT OUTER JOIN Arel node no longer - force a DISTINCT. This solves issues when using count after a left_joins. - - *Maxime Handfield Lapointe* - -* RecordNotFound raised by association.find exposes `id`, `primary_key` and - `model` methods to be consistent with RecordNotFound raised by Record.find. - - *Michel Pigassou* - -* Hashes can once again be passed to setters of `composed_of`, if all of the - mapping methods are methods implemented on `Hash`. - - Fixes #25978. - - *Sean Griffin* - -* Fix the SELECT statement in `#table_comment` for MySQL. - - *Takeshi Akima* - -* Virtual attributes will no longer raise when read on models loaded from the - database. - - *Sean Griffin* - -* Support calling the method `merge` in `scope`'s lambda. - - *Yasuhiro Sugino* - -* Fixes multi-parameter attributes conversion with invalid params. - - *Hiroyuki Ishii* - -* Add newline between each migration in `structure.sql`. - - Keeps schema migration inserts as a single commit, but allows for easier - git diffing. + *Jeremy Daer* - Fixes #25504. +* Deprecate `update_attributes`/`!` in favor of `update`/`!`. - *Grey Baker*, *Norberto Lopes* + *Eddie Lebow* -* The flag `error_on_ignored_order_or_limit` has been deprecated in favor of - the current `error_on_ignored_order`. - - *Xavier Noria* - -* Batch processing methods support `limit`: - - Post.limit(10_000).find_each do |post| - # ... - end - - It also works in `find_in_batches` and `in_batches`. - - *Xavier Noria* - -* Using `group` with an attribute that has a custom type will properly cast - the hash keys after calling a calculation method like `count`. - - Fixes #25595. - - *Sean Griffin* - -* Fix the generated `#to_param` method to use `omission: ''` so that - the resulting output is actually up to 20 characters, not - effectively 17 to leave room for the default "...". - Also call `#parameterize` before `#truncate` and make the - `separator: /-/` to maximize the information included in the - output. - - Fixes #23635. - - *Rob Biedenharn* - -* Ensure concurrent invocations of the connection reaper cannot allocate the - same connection to two threads. - - Fixes #25585. - - *Matthew Draper* - -* Inspecting an object with an associated array of over 10 elements no longer - truncates the array, preventing `inspect` from looping infinitely in some - cases. - - *Kevin McPhillips* - -* Removed the unused methods `ActiveRecord::Base.connection_id` and - `ActiveRecord::Base.connection_id=`. - - *Sean Griffin* - -* Ensure hashes can be assigned to attributes created using `composed_of`. - - Fixes #25210. - - *Sean Griffin* - -* Fix logging edge case where if an attribute was of the binary type and - was provided as a Hash. - - *Jon Moss* - -* Handle JSON deserialization correctly if the column default from database - adapter returns `''` instead of `nil`. - - *Johannes Opper* - -* Introduce `ActiveRecord::TransactionSerializationError` for catching - transaction serialization failures or deadlocks. - - *Erol Fornoles* - -* PostgreSQL: Fix `db:structure:load` silent failure on SQL error. - - The command line flag `-v ON_ERROR_STOP=1` should be used - when invoking `psql` to make sure errors are not suppressed. - - Example: +* 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. - psql -v ON_ERROR_STOP=1 -q -f awesome-file.sql my-app-db + *DHH* - Fixes #23818. +* Add `Relation#pick` as short-hand for single-value plucks. - *Ralin Chimev* + *DHH* -Please check [5-0-stable](https://github.com/rails/rails/blob/5-0-stable/activerecord/CHANGELOG.md) for previous changes. +Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activerecord/CHANGELOG.md) for previous changes. diff --git a/activerecord/MIT-LICENSE b/activerecord/MIT-LICENSE index f9e4444f07..04ba107c48 100644 --- a/activerecord/MIT-LICENSE +++ b/activerecord/MIT-LICENSE @@ -1,4 +1,6 @@ -Copyright (c) 2004-2017 David Heinemeier Hansson +Copyright (c) 2004-2018 David Heinemeier Hansson + +Arel originally copyright (c) 2007-2016 Nick Kallen, Bryan Helmkamp, Emilio Tagua, Aaron Patterson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/activerecord/README.rdoc b/activerecord/README.rdoc index cfbee4d6f7..19650b82ae 100644 --- a/activerecord/README.rdoc +++ b/activerecord/README.rdoc @@ -26,7 +26,7 @@ The Product class is automatically mapped to the table named "products", which might look like this: CREATE TABLE products ( - id int NOT NULL auto_increment, + id bigint NOT NULL auto_increment, name varchar(255), PRIMARY KEY (id) ); @@ -162,7 +162,7 @@ This would also define the following accessors: <tt>Product#name</tt> and == Philosophy Active Record is an implementation of the object-relational mapping (ORM) -pattern[http://www.martinfowler.com/eaaCatalog/activeRecord.html] by the same +pattern[https://www.martinfowler.com/eaaCatalog/activeRecord.html] by the same name described by Martin Fowler: "An object that wraps a row in a database table or view, @@ -199,7 +199,7 @@ Source code can be downloaded as part of the Rails project on GitHub: Active Record is released under the MIT license: -* http://www.opensource.org/licenses/MIT +* https://opensource.org/licenses/MIT == Support @@ -208,7 +208,7 @@ API documentation is at: * http://api.rubyonrails.org -Bug reports can be filed for the Ruby on Rails project here: +Bug reports for the Ruby on Rails project can be filed here: * https://github.com/rails/rails/issues diff --git a/activerecord/RUNNING_UNIT_TESTS.rdoc b/activerecord/RUNNING_UNIT_TESTS.rdoc index cd22f76d01..60561e2c0f 100644 --- a/activerecord/RUNNING_UNIT_TESTS.rdoc +++ b/activerecord/RUNNING_UNIT_TESTS.rdoc @@ -43,5 +43,9 @@ You can override the +connections:+ parameter in either file using the +ARCONN+ $ ARCONN=postgresql bundle exec ruby -Itest test/cases/base_test.rb +Or + + $ bundle exec rake test:postgresql TEST=test/cases/base_test.rb + You can specify a custom location for the config file using the +ARCONFIG+ environment variable. diff --git a/activerecord/Rakefile b/activerecord/Rakefile index 7be3d851f1..fae56a51bb 100644 --- a/activerecord/Rakefile +++ b/activerecord/Rakefile @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require "rake/testtask" -require File.expand_path(File.dirname(__FILE__)) + "/test/config" -require File.expand_path(File.dirname(__FILE__)) + "/test/support/config" +require_relative "test/config" +require_relative "test/support/config" def run_without_aborting(*tasks) errors = [] @@ -39,9 +41,11 @@ namespace :test do end end -desc "Build MySQL and PostgreSQL test databases" namespace :db do + desc "Build MySQL and PostgreSQL test databases" task create: ["db:mysql:build", "db:postgresql:build"] + + desc "Drop MySQL and PostgreSQL test databases" task drop: ["db:mysql:drop", "db:postgresql:drop"] end @@ -66,7 +70,7 @@ end (Dir["test/cases/**/*_test.rb"].reject { |x| x.include?("/adapters/") } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).all? do |file| - sh(Gem.ruby, "-w" , "-Itest", file) + sh(Gem.ruby, "-w", "-Itest", file) end || raise("Failures") end end @@ -90,8 +94,8 @@ namespace :db do desc "Build the MySQL test databases" task :build do config = ARTest.config["connections"]["mysql2"] - %x( mysql --user=#{config["arunit"]["username"]} --password=#{config["arunit"]["password"]} -e "create DATABASE #{config["arunit"]["database"]} DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_unicode_ci ") - %x( mysql --user=#{config["arunit2"]["username"]} --password=#{config["arunit2"]["password"]} -e "create DATABASE #{config["arunit2"]["database"]} DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_unicode_ci ") + %x( mysql --user=#{config["arunit"]["username"]} --password=#{config["arunit"]["password"]} -e "create DATABASE #{config["arunit"]["database"]} DEFAULT CHARACTER SET utf8mb4" ) + %x( mysql --user=#{config["arunit2"]["username"]} --password=#{config["arunit2"]["password"]} -e "create DATABASE #{config["arunit2"]["database"]} DEFAULT CHARACTER SET utf8mb4" ) end desc "Drop the MySQL test databases" @@ -134,7 +138,7 @@ task drop_postgresql_databases: "db:postgresql:drop" task rebuild_postgresql_databases: "db:postgresql:rebuild" task :lines do - load File.expand_path("..", File.dirname(__FILE__)) + "/tools/line_statistics" + load File.expand_path("../tools/line_statistics", __dir__) files = FileList["lib/active_record/**/*.rb"] CodeTools::LineStatistics.new(files).print_loc end diff --git a/activerecord/activerecord.gemspec b/activerecord/activerecord.gemspec index 0b37e9076c..a857d00c05 100644 --- a/activerecord/activerecord.gemspec +++ b/activerecord/activerecord.gemspec @@ -1,4 +1,6 @@ -version = File.read(File.expand_path("../../RAILS_VERSION", __FILE__)).strip +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY @@ -7,7 +9,7 @@ Gem::Specification.new do |s| s.summary = "Object-relational mapper framework (part of Rails)." s.description = "Databases on Rails. Build a persistent domain model by mapping database tables to Ruby classes. Strong conventions for associations, validations, aggregations, migrations, and testing come baked-in." - s.required_ruby_version = ">= 2.2.2" + s.required_ruby_version = ">= 2.4.1" s.license = "MIT" @@ -21,8 +23,11 @@ Gem::Specification.new do |s| s.extra_rdoc_files = %w(README.rdoc) s.rdoc_options.concat ["--main", "README.rdoc"] + s.metadata = { + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/activerecord", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/activerecord/CHANGELOG.md" + } + s.add_dependency "activesupport", version s.add_dependency "activemodel", version - - s.add_dependency "arel", "~> 8.0" end diff --git a/activerecord/bin/test b/activerecord/bin/test index 3a9547e5c1..9ecf27ce67 100755 --- a/activerecord/bin/test +++ b/activerecord/bin/test @@ -1,7 +1,14 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + +adapter_index = ARGV.index("--adapter") || ARGV.index("-a") +if adapter_index + ARGV.delete_at(adapter_index) + ENV["ARCONN"] = ARGV.delete_at(adapter_index).strip +end COMPONENT_ROOT = File.expand_path("..", __dir__) -require File.expand_path("../tools/test", COMPONENT_ROOT) +require_relative "../../tools/test" module Minitest def self.plugin_active_record_options(opts, options) @@ -16,4 +23,5 @@ module Minitest end end +Minitest.load_plugins Minitest.extensions.unshift "active_record" diff --git a/activerecord/examples/.gitignore b/activerecord/examples/.gitignore deleted file mode 100644 index 0dfc1cb7fb..0000000000 --- a/activerecord/examples/.gitignore +++ /dev/null @@ -1 +0,0 @@ -performance.sql diff --git a/activerecord/examples/performance.rb b/activerecord/examples/performance.rb index 3257dd4ad7..1a2c78f39b 100644 --- a/activerecord/examples/performance.rb +++ b/activerecord/examples/performance.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record" require "benchmark/ips" diff --git a/activerecord/examples/simple.rb b/activerecord/examples/simple.rb index c3648fee48..280b786d73 100644 --- a/activerecord/examples/simple.rb +++ b/activerecord/examples/simple.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record" class Person < ActiveRecord::Base diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 96b8545dfc..d43378c64f 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2004-2017 David Heinemeier Hansson +# Copyright (c) 2004-2018 David Heinemeier Hansson # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -25,14 +27,14 @@ require "active_support" require "active_support/rails" require "active_model" require "arel" +require "yaml" require "active_record/version" -require "active_record/attribute_set" +require "active_model/attribute_set" module ActiveRecord extend ActiveSupport::Autoload - autoload :Attribute autoload :Base autoload :Callbacks autoload :Core @@ -102,6 +104,7 @@ module ActiveRecord autoload :Result autoload :TableMetadata + autoload :Type end module Coders @@ -160,6 +163,7 @@ module ActiveRecord "active_record/tasks/postgresql_database_tasks" end + autoload :TestDatabases, "active_record/test_databases" autoload :TestFixtures, "active_record/fixtures" def self.eager_load! @@ -177,5 +181,9 @@ ActiveSupport.on_load(:active_record) do end ActiveSupport.on_load(:i18n) do - I18n.load_path << File.dirname(__FILE__) + "/active_record/locale/en.yml" + I18n.load_path << File.expand_path("active_record/locale/en.yml", __dir__) end + +YAML.load_tags["!ruby/object:ActiveRecord::AttributeSet"] = "ActiveModel::AttributeSet" +YAML.load_tags["!ruby/object:ActiveRecord::Attribute::FromDatabase"] = "ActiveModel::Attribute::FromDatabase" +YAML.load_tags["!ruby/object:ActiveRecord::LazyAttributeHash"] = "ActiveModel::LazyAttributeHash" diff --git a/activerecord/lib/active_record/aggregations.rb b/activerecord/lib/active_record/aggregations.rb index 10cbd5429c..3250e29b82 100644 --- a/activerecord/lib/active_record/aggregations.rb +++ b/activerecord/lib/active_record/aggregations.rb @@ -1,8 +1,8 @@ +# frozen_string_literal: true + module ActiveRecord # See ActiveRecord::Aggregations::ClassMethods for documentation module Aggregations - extend ActiveSupport::Concern - def initialize_dup(*) # :nodoc: @aggregation_cache = {} super @@ -33,7 +33,7 @@ module ActiveRecord # the database). # # class Customer < ActiveRecord::Base - # composed_of :balance, class_name: "Money", mapping: %w(amount currency) + # composed_of :balance, class_name: "Money", mapping: %w(balance amount) # composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ] # end # @@ -175,9 +175,9 @@ module ActiveRecord # # Once a #composed_of relationship is specified for a model, records can be loaded from the database # by specifying an instance of the value object in the conditions hash. The following example - # finds all customers with +balance_amount+ equal to 20 and +balance_currency+ equal to "USD": + # finds all customers with +address_street+ equal to "May Street" and +address_city+ equal to "Chicago": # - # Customer.where(balance: Money.new(20, "USD")) + # Customer.where(address: Address.new("May Street", "Chicago")) # module ClassMethods # Adds reader and writer methods for manipulating a value object: @@ -210,8 +210,7 @@ module ActiveRecord # # Option examples: # composed_of :temperature, mapping: %w(reading celsius) - # composed_of :balance, class_name: "Money", mapping: %w(balance amount), - # converter: Proc.new { |balance| balance.to_money } + # composed_of :balance, class_name: "Money", mapping: %w(balance amount) # composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ] # composed_of :gps_location # composed_of :gps_location, allow_nil: true @@ -224,6 +223,10 @@ module ActiveRecord def composed_of(part_id, options = {}) options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter) + unless self < Aggregations + include Aggregations + end + name = part_id.id2name class_name = options[:class_name] || name.camelize mapping = options[:mapping] || [ name, name ] diff --git a/activerecord/lib/active_record/association_relation.rb b/activerecord/lib/active_record/association_relation.rb index de2d03cd0b..403667fb70 100644 --- a/activerecord/lib/active_record/association_relation.rb +++ b/activerecord/lib/active_record/association_relation.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + module ActiveRecord class AssociationRelation < Relation - def initialize(klass, table, predicate_builder, association) - super(klass, table, predicate_builder) + def initialize(klass, association) + super(klass) @association = association end diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 4606c91ffd..1ee52945ea 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/enumerable" require "active_support/core_ext/string/conversions" require "active_support/core_ext/module/remove_method" @@ -138,26 +140,6 @@ module ActiveRecord class HasOneThroughCantAssociateThroughHasOneOrManyReflection < ThroughCantAssociateThroughHasOneOrManyReflection #:nodoc: end - class HasManyThroughCantAssociateNewRecords < ActiveRecordError #:nodoc: - def initialize(owner = nil, reflection = nil) - if owner && reflection - super("Cannot associate new records through '#{owner.class.name}##{reflection.name}' on '#{reflection.source_reflection.class_name rescue nil}##{reflection.source_reflection.name rescue nil}'. Both records must have an id in order to create the has_many :through record associating them.") - else - super("Cannot associate new records.") - end - end - end - - class HasManyThroughCantDissociateNewRecords < ActiveRecordError #:nodoc: - def initialize(owner = nil, reflection = nil) - if owner && reflection - super("Cannot dissociate new records through '#{owner.class.name}##{reflection.name}' on '#{reflection.source_reflection.class_name rescue nil}##{reflection.source_reflection.name rescue nil}'. Both records must have an id in order to delete the has_many :through record associating them.") - else - super("Cannot dissociate new records.") - end - end - end - class ThroughNestedAssociationsAreReadonly < ActiveRecordError #:nodoc: def initialize(owner = nil, reflection = nil) if owner && reflection @@ -187,16 +169,6 @@ module ActiveRecord end end - class ReadOnlyAssociation < ActiveRecordError #:nodoc: - def initialize(reflection = nil) - if reflection - super("Cannot add to a has_many :through association. Try adding to #{reflection.through_reflection.name.inspect}.") - else - super("Read-only reflection error.") - end - end - end - # This error is raised when trying to destroy a parent instance in N:1 or 1:1 associations # (has_many, has_one) when there is at least 1 child associated instance. # ex: if @project.tasks.size > 0, DeleteRestrictionError will be raised when trying to destroy @project @@ -222,13 +194,6 @@ module ActiveRecord autoload :CollectionAssociation autoload :ForeignAssociation autoload :CollectionProxy - - autoload :BelongsToAssociation - autoload :BelongsToPolymorphicAssociation - autoload :HasManyAssociation - autoload :HasManyThroughAssociation - autoload :HasOneAssociation - autoload :HasOneThroughAssociation autoload :ThroughAssociation module Builder #:nodoc: @@ -243,6 +208,13 @@ module ActiveRecord end eager_autoload do + autoload :BelongsToAssociation + autoload :BelongsToPolymorphicAssociation + autoload :HasManyAssociation + autoload :HasManyThroughAssociation + autoload :HasOneAssociation + autoload :HasOneThroughAssociation + autoload :Preloader autoload :JoinDependency autoload :AssociationScope @@ -269,7 +241,7 @@ module ActiveRecord association end - def association_cached?(name) # :nodoc + def association_cached?(name) # :nodoc: @association_cache.key?(name) end @@ -320,13 +292,13 @@ module ActiveRecord # # The project class now has the following methods (and more) to ease the traversal and # manipulation of its relationships: - # * <tt>Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil?</tt> - # * <tt>Project#project_manager, Project#project_manager=(project_manager), Project#project_manager.nil?,</tt> - # * <tt>Project#milestones.empty?, Project#milestones.size, Project#milestones, Project#milestones<<(milestone),</tt> - # <tt>Project#milestones.delete(milestone), Project#milestones.destroy(milestone), Project#milestones.find(milestone_id),</tt> - # <tt>Project#milestones.build, Project#milestones.create</tt> - # * <tt>Project#categories.empty?, Project#categories.size, Project#categories, Project#categories<<(category1),</tt> - # <tt>Project#categories.delete(category1), Project#categories.destroy(category1)</tt> + # * <tt>Project#portfolio</tt>, <tt>Project#portfolio=(portfolio)</tt>, <tt>Project#reload_portfolio</tt> + # * <tt>Project#project_manager</tt>, <tt>Project#project_manager=(project_manager)</tt>, <tt>Project#reload_project_manager</tt> + # * <tt>Project#milestones.empty?</tt>, <tt>Project#milestones.size</tt>, <tt>Project#milestones</tt>, <tt>Project#milestones<<(milestone)</tt>, + # <tt>Project#milestones.delete(milestone)</tt>, <tt>Project#milestones.destroy(milestone)</tt>, <tt>Project#milestones.find(milestone_id)</tt>, + # <tt>Project#milestones.build</tt>, <tt>Project#milestones.create</tt> + # * <tt>Project#categories.empty?</tt>, <tt>Project#categories.size</tt>, <tt>Project#categories</tt>, <tt>Project#categories<<(category1)</tt>, + # <tt>Project#categories.delete(category1)</tt>, <tt>Project#categories.destroy(category1)</tt> # # === A word of warning # @@ -342,17 +314,18 @@ module ActiveRecord # | | belongs_to | # generated methods | belongs_to | :polymorphic | has_one # ----------------------------------+------------+--------------+--------- - # other(force_reload=false) | X | X | X + # other | X | X | X # other=(other) | X | X | X # build_other(attributes={}) | X | | X # create_other(attributes={}) | X | | X # create_other!(attributes={}) | X | | X + # reload_other | X | X | X # # === Collection associations (one-to-many / many-to-many) # | | | has_many # generated methods | habtm | has_many | :through # ----------------------------------+-------+----------+---------- - # others(force_reload=false) | X | X | X + # others | X | X | X # others=(other,other,...) | X | X | X # other_ids | X | X | X # other_ids=(id,id,...) | X | X | X @@ -376,6 +349,7 @@ module ActiveRecord # others.exists? | X | X | X # others.distinct | X | X | X # others.reset | X | X | X + # others.reload | X | X | X # # === Overriding generated methods # @@ -479,14 +453,14 @@ module ActiveRecord # The tables for these classes could look something like: # # CREATE TABLE users ( - # id int NOT NULL auto_increment, - # account_id int default NULL, + # id bigint NOT NULL auto_increment, + # account_id bigint default NULL, # name varchar default NULL, # PRIMARY KEY (id) # ) # # CREATE TABLE accounts ( - # id int NOT NULL auto_increment, + # id bigint NOT NULL auto_increment, # name varchar default NULL, # PRIMARY KEY (id) # ) @@ -553,9 +527,8 @@ module ActiveRecord # has_many :birthday_events, ->(user) { where(starts_on: user.birthday) }, class_name: 'Event' # end # - # Note: Joining, eager loading and preloading of these associations is not fully possible. + # Note: Joining, eager loading and preloading of these associations is not possible. # These operations happen before instance creation and the scope will be called with a +nil+ argument. - # This can lead to unexpected behavior and is deprecated. # # == Association callbacks # @@ -846,7 +819,7 @@ module ActiveRecord # project.milestones # fetches milestones from the database # project.milestones.size # uses the milestone cache # project.milestones.empty? # uses the milestone cache - # project.milestones(true).size # fetches milestones from the database + # project.milestones.reload.size # fetches milestones from the database # project.milestones # uses the milestone cache # # == Eager loading of associations @@ -1088,12 +1061,6 @@ module ActiveRecord # belongs_to :dungeon, inverse_of: :evil_wizard # end # - # There are limitations to <tt>:inverse_of</tt> support: - # - # * does not work with <tt>:through</tt> associations. - # * does not work with <tt>:polymorphic</tt> associations. - # * inverse associations for #belongs_to associations #has_many are ignored. - # # For more information, see the documentation for the +:inverse_of+ option. # # == Deleting from associations @@ -1187,9 +1154,9 @@ module ActiveRecord # +collection+ is a placeholder for the symbol passed as the +name+ argument, so # <tt>has_many :clients</tt> would add among others <tt>clients.empty?</tt>. # - # [collection(force_reload = false)] - # Returns an array of all the associated objects. - # An empty array is returned if none are found. + # [collection] + # Returns a Relation of all the associated objects. + # An empty Relation is returned if none are found. # [collection<<(object, ...)] # Adds one or more objects to the collection by setting their foreign keys to the collection's primary key. # Note that this operation instantly fires update SQL without waiting for the save or update call on the @@ -1246,6 +1213,9 @@ module ActiveRecord # [collection.create!(attributes = {})] # Does the same as <tt>collection.create</tt>, but raises ActiveRecord::RecordInvalid # if the record is invalid. + # [collection.reload] + # Returns a Relation of all of the associated objects, forcing a database read. + # An empty Relation is returned if none are found. # # === Example # @@ -1262,9 +1232,10 @@ module ActiveRecord # * <tt>Firm#clients.size</tt> (similar to <tt>Client.count "firm_id = #{id}"</tt>) # * <tt>Firm#clients.find</tt> (similar to <tt>Client.where(firm_id: id).find(id)</tt>) # * <tt>Firm#clients.exists?(name: 'ACME')</tt> (similar to <tt>Client.exists?(name: 'ACME', firm_id: firm.id)</tt>) - # * <tt>Firm#clients.build</tt> (similar to <tt>Client.new("firm_id" => id)</tt>) - # * <tt>Firm#clients.create</tt> (similar to <tt>c = Client.new("firm_id" => id); c.save; c</tt>) - # * <tt>Firm#clients.create!</tt> (similar to <tt>c = Client.new("firm_id" => id); c.save!</tt>) + # * <tt>Firm#clients.build</tt> (similar to <tt>Client.new(firm_id: id)</tt>) + # * <tt>Firm#clients.create</tt> (similar to <tt>c = Client.new(firm_id: id); c.save; c</tt>) + # * <tt>Firm#clients.create!</tt> (similar to <tt>c = Client.new(firm_id: id); c.save!</tt>) + # * <tt>Firm#clients.reload</tt> # The declaration can also include an +options+ hash to specialize the behavior of the association. # # === Scopes @@ -1276,7 +1247,7 @@ module ActiveRecord # Scope examples: # has_many :comments, -> { where(author_id: 1) } # has_many :employees, -> { joins(:address) } - # has_many :posts, ->(post) { where("max_post_length > ?", post.length) } + # has_many :posts, ->(blog) { where("max_post_length > ?", blog.max_post_length) } # # === Extensions # @@ -1302,6 +1273,9 @@ module ActiveRecord # Specify the foreign key used for the association. By default this is guessed to be the name # of this class in lower-case and "_id" suffixed. So a Person class that makes a #has_many # association will use "person_id" as the default <tt>:foreign_key</tt>. + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the <tt>:inverse_of</tt> option. # [:foreign_type] # Specify the column used to store the associated object's type, if this is a polymorphic # association. By default this is guessed to be the name of the polymorphic association @@ -1375,8 +1349,7 @@ module ActiveRecord # <tt>:autosave</tt> to <tt>true</tt>. # [:inverse_of] # Specifies the name of the #belongs_to association on the associated object - # that is the inverse of this #has_many association. Does not work in combination - # with <tt>:through</tt> or <tt>:as</tt> options. + # that is the inverse of this #has_many association. # See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail. # [:extend] # Specifies a module or array of modules that will be extended into the association object returned. @@ -1392,7 +1365,7 @@ module ActiveRecord # has_many :tags, as: :taggable # has_many :reports, -> { readonly } # has_many :subscribers, through: :subscriptions, source: :user - def has_many(name, scope = nil, options = {}, &extension) + def has_many(name, scope = nil, **options, &extension) reflection = Builder::HasMany.build(self, name, scope, options, &extension) Reflection.add_reflection self, name, reflection end @@ -1407,7 +1380,7 @@ module ActiveRecord # +association+ is a placeholder for the symbol passed as the +name+ argument, so # <tt>has_one :manager</tt> would add among others <tt>manager.nil?</tt>. # - # [association(force_reload = false)] + # [association] # Returns the associated object. +nil+ is returned if none is found. # [association=(associate)] # Assigns the associate object, extracts the primary key, sets it as the foreign key, @@ -1424,15 +1397,18 @@ module ActiveRecord # [create_association!(attributes = {})] # Does the same as <tt>create_association</tt>, but raises ActiveRecord::RecordInvalid # if the record is invalid. + # [reload_association] + # Returns the associated object, forcing a database read. # # === Example # # An Account class declares <tt>has_one :beneficiary</tt>, which will add: # * <tt>Account#beneficiary</tt> (similar to <tt>Beneficiary.where(account_id: id).first</tt>) # * <tt>Account#beneficiary=(beneficiary)</tt> (similar to <tt>beneficiary.account_id = account.id; beneficiary.save</tt>) - # * <tt>Account#build_beneficiary</tt> (similar to <tt>Beneficiary.new("account_id" => id)</tt>) - # * <tt>Account#create_beneficiary</tt> (similar to <tt>b = Beneficiary.new("account_id" => id); b.save; b</tt>) - # * <tt>Account#create_beneficiary!</tt> (similar to <tt>b = Beneficiary.new("account_id" => id); b.save!; b</tt>) + # * <tt>Account#build_beneficiary</tt> (similar to <tt>Beneficiary.new(account_id: id)</tt>) + # * <tt>Account#create_beneficiary</tt> (similar to <tt>b = Beneficiary.new(account_id: id); b.save; b</tt>) + # * <tt>Account#create_beneficiary!</tt> (similar to <tt>b = Beneficiary.new(account_id: id); b.save!; b</tt>) + # * <tt>Account#reload_beneficiary</tt> # # === Scopes # @@ -1443,7 +1419,7 @@ module ActiveRecord # Scope examples: # has_one :author, -> { where(comment_id: 1) } # has_one :employer, -> { joins(:company) } - # has_one :dob, ->(dob) { where("Date.new(2000, 01, 01) > ?", dob) } + # has_one :latest_post, ->(blog) { where("created_at > ?", blog.enabled_at) } # # === Options # @@ -1469,6 +1445,9 @@ module ActiveRecord # Specify the foreign key used for the association. By default this is guessed to be the name # of this class in lower-case and "_id" suffixed. So a Person class that makes a #has_one association # will use "person_id" as the default <tt>:foreign_key</tt>. + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the <tt>:inverse_of</tt> option. # [:foreign_type] # Specify the column used to store the associated object's type, if this is a polymorphic # association. By default this is guessed to be the name of the polymorphic association @@ -1484,6 +1463,9 @@ module ActiveRecord # <tt>:primary_key</tt>, and <tt>:foreign_key</tt> are ignored, as the association uses the # source reflection. You can only use a <tt>:through</tt> query through a #has_one # or #belongs_to association on the join model. + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the <tt>:inverse_of</tt> option. # [:source] # Specifies the source association name used by #has_one <tt>:through</tt> queries. # Only use it if the name cannot be inferred from the association. @@ -1504,8 +1486,7 @@ module ActiveRecord # <tt>:autosave</tt> to <tt>true</tt>. # [:inverse_of] # Specifies the name of the #belongs_to association on the associated object - # that is the inverse of this #has_one association. Does not work in combination - # with <tt>:through</tt> or <tt>:as</tt> options. + # that is the inverse of this #has_one association. # See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail. # [:required] # When set to +true+, the association will also have its presence validated. @@ -1523,7 +1504,7 @@ module ActiveRecord # has_one :club, through: :membership # has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable # has_one :credit_card, required: true - def has_one(name, scope = nil, options = {}) + def has_one(name, scope = nil, **options) reflection = Builder::HasOne.build(self, name, scope, options) Reflection.add_reflection self, name, reflection end @@ -1539,7 +1520,7 @@ module ActiveRecord # +association+ is a placeholder for the symbol passed as the +name+ argument, so # <tt>belongs_to :author</tt> would add among others <tt>author.nil?</tt>. # - # [association(force_reload = false)] + # [association] # Returns the associated object. +nil+ is returned if none is found. # [association=(associate)] # Assigns the associate object, extracts the primary key, and sets it as the foreign key. @@ -1553,6 +1534,8 @@ module ActiveRecord # [create_association!(attributes = {})] # Does the same as <tt>create_association</tt>, but raises ActiveRecord::RecordInvalid # if the record is invalid. + # [reload_association] + # Returns the associated object, forcing a database read. # # === Example # @@ -1562,6 +1545,7 @@ module ActiveRecord # * <tt>Post#build_author</tt> (similar to <tt>post.author = Author.new</tt>) # * <tt>Post#create_author</tt> (similar to <tt>post.author = Author.new; post.author.save; post.author</tt>) # * <tt>Post#create_author!</tt> (similar to <tt>post.author = Author.new; post.author.save!; post.author</tt>) + # * <tt>Post#reload_author</tt> # The declaration can also include an +options+ hash to specialize the behavior of the association. # # === Scopes @@ -1573,7 +1557,7 @@ module ActiveRecord # Scope examples: # belongs_to :firm, -> { where(id: 2) } # belongs_to :user, -> { joins(:friends) } - # belongs_to :level, ->(level) { where("game_level > ?", level.current) } + # belongs_to :level, ->(game) { where("game_level > ?", game.current_level) } # # === Options # @@ -1587,6 +1571,9 @@ module ActiveRecord # association will use "person_id" as the default <tt>:foreign_key</tt>. Similarly, # <tt>belongs_to :favorite_person, class_name: "Person"</tt> will use a foreign key # of "favorite_person_id". + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the <tt>:inverse_of</tt> option. # [:foreign_type] # Specify the column used to store the associated object's type, if this is a polymorphic # association. By default this is guessed to be the name of the association with a "_type" @@ -1636,8 +1623,7 @@ module ActiveRecord # +after_commit+ and +after_rollback+ callbacks are executed. # [:inverse_of] # Specifies the name of the #has_one or #has_many association on the associated - # object that is the inverse of this #belongs_to association. Does not work in - # combination with the <tt>:polymorphic</tt> options. + # object that is the inverse of this #belongs_to association. # See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail. # [:optional] # When set to +true+, the association will not have its presence validated. @@ -1647,6 +1633,9 @@ module ActiveRecord # +:inverse_of+ to avoid an extra query during validation. # NOTE: <tt>required</tt> is set to <tt>true</tt> by default and is deprecated. If # you don't want to have association presence validated, use <tt>optional: true</tt>. + # [:default] + # Provide a callable (i.e. proc or lambda) to specify that the association should + # be initialized with a particular record before validation. # # Option examples: # belongs_to :firm, foreign_key: "client_of" @@ -1660,7 +1649,8 @@ module ActiveRecord # belongs_to :comment, touch: true # belongs_to :company, touch: :employees_last_updated_at # belongs_to :user, optional: true - def belongs_to(name, scope = nil, options = {}) + # belongs_to :account, default: -> { company.account } + def belongs_to(name, scope = nil, **options) reflection = Builder::BelongsTo.build(self, name, scope, options) Reflection.add_reflection self, name, reflection end @@ -1697,9 +1687,9 @@ module ActiveRecord # +collection+ is a placeholder for the symbol passed as the +name+ argument, so # <tt>has_and_belongs_to_many :categories</tt> would add among others <tt>categories.empty?</tt>. # - # [collection(force_reload = false)] - # Returns an array of all the associated objects. - # An empty array is returned if none are found. + # [collection] + # Returns a Relation of all the associated objects. + # An empty Relation is returned if none are found. # [collection<<(object, ...)] # Adds one or more objects to the collection by creating associations in the join table # (<tt>collection.push</tt> and <tt>collection.concat</tt> are aliases to this method). @@ -1737,6 +1727,9 @@ module ActiveRecord # Returns a new object of the collection type that has been instantiated # with +attributes+, linked to this object through the join table, and that has already been # saved (if it passed the validation). + # [collection.reload] + # Returns a Relation of all of the associated objects, forcing a database read. + # An empty Relation is returned if none are found. # # === Example # @@ -1753,8 +1746,9 @@ module ActiveRecord # * <tt>Developer#projects.size</tt> # * <tt>Developer#projects.find(id)</tt> # * <tt>Developer#projects.exists?(...)</tt> - # * <tt>Developer#projects.build</tt> (similar to <tt>Project.new("developer_id" => id)</tt>) - # * <tt>Developer#projects.create</tt> (similar to <tt>c = Project.new("developer_id" => id); c.save; c</tt>) + # * <tt>Developer#projects.build</tt> (similar to <tt>Project.new(developer_id: id)</tt>) + # * <tt>Developer#projects.create</tt> (similar to <tt>c = Project.new(developer_id: id); c.save; c</tt>) + # * <tt>Developer#projects.reload</tt> # The declaration may include an +options+ hash to specialize the behavior of the association. # # === Scopes @@ -1765,9 +1759,8 @@ module ActiveRecord # # Scope examples: # has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) } - # has_and_belongs_to_many :categories, ->(category) { - # where("default_category = ?", category.name) - # } + # has_and_belongs_to_many :categories, ->(post) { + # where("default_category = ?", post.default_category) # # === Extensions # @@ -1799,6 +1792,9 @@ module ActiveRecord # of this class in lower-case and "_id" suffixed. So a Person class that makes # a #has_and_belongs_to_many association to Project will use "person_id" as the # default <tt>:foreign_key</tt>. + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the <tt>:inverse_of</tt> option. # [:association_foreign_key] # Specify the foreign key used for the association on the receiving side of the association. # By default this is guessed to be the name of the associated class in lower-case and "_id" suffixed. @@ -1827,7 +1823,7 @@ module ActiveRecord builder = Builder::HasAndBelongsToMany.new name, self, options - join_model = ActiveSupport::Deprecation.silence { builder.through_model } + join_model = builder.through_model const_set join_model.name, join_model private_constant join_model.name @@ -1856,7 +1852,7 @@ module ActiveRecord hm_options[k] = options[k] if options.key? k end - ActiveSupport::Deprecation.silence { has_many name, scope, hm_options, &extension } + has_many name, scope, hm_options, &extension _reflections[name.to_s].parent_reflection = habtm_reflection end end diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 3963008a76..272eede824 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -1,51 +1,41 @@ +# frozen_string_literal: true + require "active_support/core_ext/string/conversions" module ActiveRecord module Associations # Keeps track of table aliases for ActiveRecord::Associations::JoinDependency class AliasTracker # :nodoc: - attr_reader :aliases - - def self.create(connection, initial_table, type_caster) - aliases = Hash.new(0) - aliases[initial_table] = 1 - new connection, aliases, type_caster - end - - def self.create_with_joins(connection, initial_table, joins, type_caster) + def self.create(connection, initial_table, joins) if joins.empty? - create(connection, initial_table, type_caster) + aliases = Hash.new(0) else aliases = Hash.new { |h, k| h[k] = initial_count_for(connection, k, joins) } - aliases[initial_table] = 1 - new connection, aliases, type_caster end + aliases[initial_table] = 1 + new(connection, aliases) end def self.initial_count_for(connection, name, table_joins) - # quoted_name should be downcased as some database adapters (Oracle) return quoted name in uppercase - quoted_name = connection.quote_table_name(name).downcase + quoted_name = nil counts = table_joins.map do |join| if join.is_a?(Arel::Nodes::StringJoin) + # quoted_name should be case ignored as some database adapters (Oracle) return quoted name in uppercase + quoted_name ||= connection.quote_table_name(name) + # Table names + table aliases - join.left.downcase.scan( - /join(?:\s+\w+)?\s+(\S+\s+)?#{quoted_name}\son/ + join.left.scan( + /JOIN(?:\s+\w+)?\s+(?:\S+\s+)?(?:#{quoted_name}|#{name})\sON/i ).size - elsif join.respond_to? :left - join.left.table_name == name ? 1 : 0 + elsif join.is_a?(Arel::Nodes::Join) + join.left.name == name ? 1 : 0 + elsif join.is_a?(Hash) + join[name] else - # this branch is reached by two tests: - # - # activerecord/test/cases/associations/cascaded_eager_loading_test.rb:37 - # with :posts - # - # activerecord/test/cases/associations/eager_test.rb:1133 - # with :comments - # - 0 + raise ArgumentError, "joins list should be initialized by list of Arel::Nodes::Join" end end @@ -53,17 +43,16 @@ module ActiveRecord end # table_joins is an array of arel joins which might conflict with the aliases we assign here - def initialize(connection, aliases, type_caster) + def initialize(connection, aliases) @aliases = aliases @connection = connection - @type_caster = type_caster end - def aliased_table_for(table_name, aliased_name) + def aliased_table_for(table_name, aliased_name, type_caster) if aliases[table_name].zero? # If it's zero, we can have our table_name aliases[table_name] = 1 - Arel::Table.new(table_name, type_caster: @type_caster) + Arel::Table.new(table_name, type_caster: type_caster) else # Otherwise, we need to use an alias aliased_name = @connection.table_alias_for(aliased_name) @@ -76,10 +65,12 @@ module ActiveRecord else aliased_name end - Arel::Table.new(table_name, type_caster: @type_caster).alias(table_alias) + Arel::Table.new(table_name, type_caster: type_caster).alias(table_alias) end end + attr_reader :aliases + private def truncate(name) diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index 1cb2b2d7c6..44596f4424 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/array/wrap" module ActiveRecord @@ -17,7 +19,6 @@ module ActiveRecord # HasManyThroughAssociation + ThroughAssociation class Association #:nodoc: attr_reader :owner, :target, :reflection - attr_accessor :inversed delegate :options, to: :reflection @@ -30,14 +31,6 @@ module ActiveRecord reset_scope end - # Returns the name of the table of the associated class: - # - # post.comments.aliased_table_name # => "comments" - # - def aliased_table_name - klass.table_name - end - # Resets the \loaded flag to +false+ and sets the \target to +nil+. def reset @loaded = false @@ -73,7 +66,7 @@ module ActiveRecord # # Note that if the target has not been loaded, it is not considered stale. def stale_target? - !inversed && loaded? && @stale_state != stale_state + !@inversed && loaded? && @stale_state != stale_state end # Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+. @@ -94,7 +87,7 @@ module ActiveRecord # actually gets built. def association_scope if klass - @association_scope ||= AssociationScope.scope(self, klass.connection) + @association_scope ||= AssociationScope.scope(self) end end @@ -104,23 +97,24 @@ module ActiveRecord # Set the inverse association, if possible def set_inverse_instance(record) - if invertible_for?(record) - inverse = record.association(inverse_reflection_for(record).name) - inverse.target = owner - inverse.inversed = true + if inverse = inverse_association_for(record) + inverse.inversed_from(owner) end record end # Remove the inverse association, if possible def remove_inverse_instance(record) - if invertible_for?(record) - inverse = record.association(inverse_reflection_for(record).name) - inverse.target = nil - inverse.inversed = false + if inverse = inverse_association_for(record) + inverse.inversed_from(nil) end end + def inversed_from(record) + self.target = record + @inversed = !!record + end + # Returns the class of the target. belongs_to polymorphic overrides this to look at the # polymorphic_type field on the owner. def klass @@ -130,7 +124,17 @@ 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, klass.arel_table, klass.predicate_builder, self).merge!(klass.all) + AssociationRelation.create(klass, self).merge!(klass.all) + end + + def extensions + extensions = klass.default_extensions | reflection.extensions + + if reflection.scope + extensions |= reflection.scope_for(klass.unscoped, owner).extensions + end + + extensions end # Loads the \target if needed and returns it. @@ -152,17 +156,9 @@ module ActiveRecord reset end - def interpolate(sql, record = nil) - if sql.respond_to?(:to_proc) - owner.instance_exec(record, &sql) - else - sql - end - end - - # We can't dump @reflection since it contains the scope proc + # We can't dump @reflection and @through_reflection since it contains the scope proc def marshal_dump - ivars = (instance_variables - [:@reflection]).map { |name| [name, instance_variable_get(name)] } + ivars = (instance_variables - [:@reflection, :@through_reflection]).map { |name| [name, instance_variable_get(name)] } [@reflection.name, ivars] end @@ -177,8 +173,8 @@ module ActiveRecord skip_assign = [reflection.foreign_key, reflection.type].compact assigned_keys = record.changed_attribute_names_to_save assigned_keys += except_from_scope_attributes.keys.map(&:to_s) - attributes = create_scope.except(*(assigned_keys - skip_assign)) - record.assign_attributes(attributes) + attributes = scope_for_create.except!(*(assigned_keys - skip_assign)) + record.send(:_assign_attributes, attributes) if attributes.any? set_inverse_instance(record) end @@ -191,6 +187,9 @@ module ActiveRecord end private + def scope_for_create + scope.scope_for_create + end def find_target? !loaded? && (!owner.new_record? || foreign_key_present?) && klass @@ -202,8 +201,8 @@ module ActiveRecord if (reflection.has_one? || reflection.collection?) && !options[:through] attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key] - if reflection.options[:as] - attributes[reflection.type] = owner.class.base_class.name + if reflection.type + attributes[reflection.type] = owner.class.polymorphic_name end end @@ -241,6 +240,12 @@ module ActiveRecord end end + def inverse_association_for(record) + if invertible_for?(record) + record.association(inverse_reflection_for(record).name) + end + end + # Can be redefined by subclasses, notably polymorphic belongs_to # The record parameter is necessary to support polymorphic inverses as we must check for # the association in the specific class of the record. @@ -270,11 +275,12 @@ module ActiveRecord def build_record(attributes) reflection.build_association(attributes) do |record| initialize_attributes(record, attributes) + yield(record) if block_given? end end # Returns true if statement cache should be skipped on the association reader. - def skip_statement_cache? + def skip_statement_cache?(scope) reflection.has_scope? || scope.eager_loading? || klass.scope_attributes? || diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index badde9973f..0a90a6104a 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module ActiveRecord module Associations class AssociationScope #:nodoc: - def self.scope(association, connection) - INSTANCE.scope(association, connection) + def self.scope(association) + INSTANCE.scope(association) end def self.create(&block) @@ -16,20 +18,15 @@ module ActiveRecord INSTANCE = create - def scope(association, connection) + def scope(association) klass = association.klass reflection = association.reflection scope = klass.unscoped owner = association.owner - alias_tracker = AliasTracker.create connection, association.klass.table_name, klass.type_caster - chain_head, chain_tail = get_chain(reflection, association, alias_tracker) - - scope.extending! Array(reflection.options[:extend]) - add_constraints(scope, owner, klass, reflection, chain_head, chain_tail) - end + chain = get_chain(reflection, association, scope.alias_tracker) - def join_type - Arel::Nodes::InnerJoin + scope.extending! reflection.extensions + add_constraints(scope, owner, chain) end def self.get_bind_values(owner, chain) @@ -38,39 +35,36 @@ module ActiveRecord binds << last_reflection.join_id_for(owner) if last_reflection.type - binds << owner.class.base_class.name + binds << owner.class.polymorphic_name end chain.each_cons(2).each do |reflection, next_reflection| if reflection.type - binds << next_reflection.klass.base_class.name + binds << next_reflection.klass.polymorphic_name end end binds end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :value_transformation - private def join(table, constraint) - table.create_join(table, table.create_on(constraint), join_type) + table.create_join(table, table.create_on(constraint)) end - def last_chain_scope(scope, table, reflection, owner, association_klass) - join_keys = reflection.join_keys(association_klass) + def last_chain_scope(scope, reflection, owner) + join_keys = reflection.join_keys key = join_keys.key foreign_key = join_keys.foreign_key + table = reflection.aliased_table value = transform_value(owner[foreign_key]) - scope = scope.where(table.name => { key => value }) + scope = apply_scope(scope, table, key, value) if reflection.type - polymorphic_type = transform_value(owner.class.base_class.name) - scope = scope.where(table.name => { reflection.type => polymorphic_type }) + polymorphic_type = transform_value(owner.class.polymorphic_name) + scope = apply_scope(scope, table, reflection.type, polymorphic_type) end scope @@ -80,28 +74,29 @@ module ActiveRecord value_transformation.call(value) end - def next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection) - join_keys = reflection.join_keys(association_klass) + def next_chain_scope(scope, reflection, next_reflection) + join_keys = reflection.join_keys key = join_keys.key foreign_key = join_keys.foreign_key + table = reflection.aliased_table + foreign_table = next_reflection.aliased_table constraint = table[key].eq(foreign_table[foreign_key]) if reflection.type - value = transform_value(next_reflection.klass.base_class.name) - scope = scope.where(table.name => { reflection.type => value }) + value = transform_value(next_reflection.klass.polymorphic_name) + scope = apply_scope(scope, table, reflection.type, value) end - scope = scope.joins(join(foreign_table, constraint)) + scope.joins!(join(foreign_table, constraint)) end class ReflectionProxy < SimpleDelegator # :nodoc: - attr_accessor :next - attr_reader :alias_name + attr_reader :aliased_table - def initialize(reflection, alias_name) + def initialize(reflection, aliased_table) super(reflection) - @alias_name = alias_name + @aliased_table = aliased_table end def all_includes; nil; end @@ -109,39 +104,34 @@ module ActiveRecord def get_chain(reflection, association, tracker) name = reflection.name - runtime_reflection = Reflection::RuntimeReflection.new(reflection, association) - previous_reflection = runtime_reflection + chain = [Reflection::RuntimeReflection.new(reflection, association)] reflection.chain.drop(1).each do |refl| - alias_name = tracker.aliased_table_for(refl.table_name, refl.alias_candidate(name)) - proxy = ReflectionProxy.new(refl, alias_name) - previous_reflection.next = proxy - previous_reflection = proxy + aliased_table = tracker.aliased_table_for( + refl.table_name, + refl.alias_candidate(name), + refl.klass.type_caster + ) + chain << ReflectionProxy.new(refl, aliased_table) end - [runtime_reflection, previous_reflection] + chain end - def add_constraints(scope, owner, association_klass, refl, chain_head, chain_tail) - owner_reflection = chain_tail - table = owner_reflection.alias_name - scope = last_chain_scope(scope, table, owner_reflection, owner, association_klass) - - reflection = chain_head - while reflection - table = reflection.alias_name - next_reflection = reflection.next + def add_constraints(scope, owner, chain) + scope = last_chain_scope(scope, chain.last, owner) - unless reflection == chain_tail - foreign_table = next_reflection.alias_name - scope = next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection) - end + chain.each_cons(2) do |reflection, next_reflection| + scope = next_chain_scope(scope, reflection, next_reflection) + end + chain_head = chain.first + chain.reverse_each do |reflection| # Exclude the scope of the association itself, because that # was already merged in the #scope method. reflection.constraints.each do |scope_chain_item| - item = eval_scope(reflection.klass, table, scope_chain_item, owner) + item = eval_scope(reflection, scope_chain_item, owner) - if scope_chain_item == refl.scope - scope.merge! item.except(:where, :includes) + if scope_chain_item == chain_head.scope + scope.merge! item.except(:where, :includes, :unscope, :order) end reflection.all_includes do @@ -150,18 +140,24 @@ module ActiveRecord scope.unscope!(*item.unscope_values) scope.where_clause += item.where_clause - scope.order_values |= item.order_values + scope.order_values = item.order_values | scope.order_values end - - reflection = next_reflection end scope end - def eval_scope(klass, table, scope, owner) - predicate_builder = PredicateBuilder.new(TableMetadata.new(klass, table)) - ActiveRecord::Relation.create(klass, table, predicate_builder).instance_exec(owner, &scope) + def apply_scope(scope, table, key, value) + if scope.table == table + scope.where!(key => value) + else + scope.where!(table.name => { key => value }) + end + end + + def eval_scope(reflection, scope, owner) + relation = reflection.build_scope(reflection.aliased_table) + relation.instance_exec(owner, &scope) || relation end end end diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 64b2311911..3d4ad1dd5b 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -1,24 +1,28 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Belongs To Association module Associations + # = Active Record Belongs To Association class BelongsToAssociation < SingularAssociation #:nodoc: def handle_dependency - target.send(options[:dependent]) if load_target - end + return unless load_target - def replace(record) - if record - raise_on_type_mismatch!(record) - update_counters_on_replace(record) - replace_keys(record) - set_inverse_instance(record) - @updated = true + case options[:dependent] + when :destroy + target.destroy + raise ActiveRecord::Rollback unless target.destroyed? else - decrement_counters - remove_keys + target.send(options[:dependent]) end + end - self.target = record + def inversed_from(record) + replace_keys(record) + super + end + + def default(&block) + writer(owner.instance_exec(&block)) if reader.nil? end def reset @@ -38,14 +42,32 @@ module ActiveRecord update_counters(1) end + def target_changed? + owner.saved_change_to_attribute?(reflection.foreign_key) + end + private + def replace(record) + if record + raise_on_type_mismatch!(record) + update_counters_on_replace(record) + set_inverse_instance(record) + @updated = true + else + decrement_counters + end + + replace_keys(record) + + self.target = record + end def update_counters(by) if require_counter_update? && foreign_key_present? if target && !stale_target? - target.increment!(reflection.counter_cache_column, by) + target.increment!(reflection.counter_cache_column, by, touch: reflection.options[:touch]) else - klass.update_counters(target_id, reflection.counter_cache_column => by) + counter_cache_target.update_counters(reflection.counter_cache_column => by, touch: reflection.options[:touch]) end end end @@ -61,22 +83,22 @@ module ActiveRecord def update_counters_on_replace(record) if require_counter_update? && different_target?(record) owner.instance_variable_set :@_after_replace_counter_called, true - record.increment!(reflection.counter_cache_column) + record.increment!(reflection.counter_cache_column, touch: reflection.options[:touch]) decrement_counters end end # Checks whether record is different to the current target, without loading it def different_target?(record) - record.id != owner._read_attribute(reflection.foreign_key) + record._read_attribute(primary_key(record)) != owner._read_attribute(reflection.foreign_key) end def replace_keys(record) - owner[reflection.foreign_key] = record._read_attribute(reflection.association_primary_key(record.class)) + owner[reflection.foreign_key] = record ? record._read_attribute(primary_key(record)) : nil end - def remove_keys - owner[reflection.foreign_key] = nil + def primary_key(record) + reflection.association_primary_key(record.class) end def foreign_key_present? @@ -90,12 +112,9 @@ module ActiveRecord inverse && inverse.has_one? end - def target_id - if options[:primary_key] - owner.send(reflection.name).try(:id) - else - owner._read_attribute(reflection.foreign_key) - end + def counter_cache_target + primary_key = reflection.association_primary_key(klass) + klass.unscoped.where!(primary_key => owner._read_attribute(reflection.foreign_key)) end def stale_state diff --git a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb index b710cf6bdb..3fd2fb5f67 100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb @@ -1,22 +1,22 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Belongs To Polymorphic Association module Associations + # = Active Record Belongs To Polymorphic Association class BelongsToPolymorphicAssociation < BelongsToAssociation #:nodoc: def klass type = owner[reflection.foreign_type] type.presence && type.constantize end - private + def target_changed? + super || owner.saved_change_to_attribute?(reflection.foreign_type) + end + private def replace_keys(record) super - owner[reflection.foreign_type] = record.class.base_class.name - end - - def remove_keys - super - owner[reflection.foreign_type] = nil + owner[reflection.foreign_type] = record ? record.class.polymorphic_name : nil end def different_target?(record) diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb index d0534056d9..7c69cd65ee 100644 --- a/activerecord/lib/active_record/associations/builder/association.rb +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This is the parent Association class which defines the variables # used by all associations. # @@ -36,11 +38,6 @@ module ActiveRecord::Associations::Builder # :nodoc: def self.create_reflection(model, name, scope, options, extension = nil) raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol) - if scope.is_a?(Hash) - options = scope - scope = nil - end - validate_options(options) scope = build_scope(scope, extension) @@ -107,8 +104,8 @@ module ActiveRecord::Associations::Builder # :nodoc: def self.define_readers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 - def #{name}(*args) - association(:#{name}).reader(*args) + def #{name} + association(:#{name}).reader end CODE end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index a1609ab0fb..0166ed98ca 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord::Associations::Builder # :nodoc: class BelongsTo < SingularAssociation #:nodoc: def self.macro @@ -5,7 +7,7 @@ module ActiveRecord::Associations::Builder # :nodoc: end def self.valid_options(options) - super + [:polymorphic, :touch, :counter_cache, :optional] + super + [:polymorphic, :touch, :counter_cache, :optional, :default] end def self.valid_dependent_options @@ -16,6 +18,7 @@ module ActiveRecord::Associations::Builder # :nodoc: super add_counter_cache_callbacks(model, reflection) if reflection.options[:counter_cache] add_touch_callbacks(model, reflection) if reflection.options[:touch] + add_default_callbacks(model, reflection) if reflection.options[:default] end def self.define_accessors(mixin, reflection) @@ -31,11 +34,9 @@ module ActiveRecord::Associations::Builder # :nodoc: foreign_key = reflection.foreign_key cache_column = reflection.counter_cache_column - if (@_after_create_counter_called ||= false) - @_after_create_counter_called = false - elsif (@_after_replace_counter_called ||= false) + if @_after_replace_counter_called ||= false @_after_replace_counter_called = false - elsif saved_change_to_attribute?(foreign_key) && !new_record? + elsif association(reflection.name).target_changed? if reflection.polymorphic? model = attribute_in_database(reflection.foreign_type).try(:constantize) model_was = attribute_before_last_save(reflection.foreign_type).try(:constantize) @@ -47,15 +48,21 @@ module ActiveRecord::Associations::Builder # :nodoc: foreign_key_was = attribute_before_last_save foreign_key foreign_key = attribute_in_database foreign_key - if foreign_key && model.respond_to?(:increment_counter) - model.increment_counter(cache_column, foreign_key) + if foreign_key && model < ActiveRecord::Base + counter_cache_target(reflection, model, foreign_key).update_counters(cache_column => 1) end - if foreign_key_was && model_was.respond_to?(:decrement_counter) - model_was.decrement_counter(cache_column, foreign_key_was) + if foreign_key_was && model_was < ActiveRecord::Base + counter_cache_target(reflection, model_was, foreign_key_was).update_counters(cache_column => -1) end end end + + private + def counter_cache_target(reflection, model, foreign_key) + primary_key = reflection.association_primary_key(model) + model.unscoped.where!(primary_key => foreign_key) + end end end @@ -83,7 +90,8 @@ module ActiveRecord::Associations::Builder # :nodoc: else klass = association.klass end - old_record = klass.find_by(klass.primary_key => old_foreign_id) + primary_key = reflection.association_primary_key(klass) + old_record = klass.find_by(primary_key => old_foreign_id) if old_record if touch != true @@ -113,9 +121,19 @@ module ActiveRecord::Associations::Builder # :nodoc: BelongsTo.touch_record(record, record.send(changes_method), foreign_key, n, touch, belongs_to_touch_method) }} - model.after_save callback.(:saved_changes), if: :saved_changes? - model.after_touch callback.(:changes_to_save) - model.after_destroy callback.(:changes_to_save) + unless reflection.counter_cache_column + model.after_create callback.(:saved_changes), if: :saved_changes? + model.after_destroy callback.(:changes_to_save) + end + + model.after_update callback.(:saved_changes), if: :saved_changes? + model.after_touch callback.(:changes_to_save) + end + + def self.add_default_callbacks(model, reflection) + model.before_validation lambda { |o| + o.association(reflection.name).default(&reflection.options[:default]) + } end def self.add_destroy_callbacks(model, reflection) diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index edeb6491bd..35a72c3850 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -1,4 +1,4 @@ -# This class is inherited by the has_many and has_many_and_belongs_to_many association classes +# frozen_string_literal: true require "active_record/associations" diff --git a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb index 6b71826431..e3070e0472 100644 --- a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -1,38 +1,7 @@ +# frozen_string_literal: true + module ActiveRecord::Associations::Builder # :nodoc: class HasAndBelongsToMany # :nodoc: - class JoinTableResolver # :nodoc: - KnownTable = Struct.new :join_table - - class KnownClass # :nodoc: - def initialize(lhs_class, rhs_class_name) - @lhs_class = lhs_class - @rhs_class_name = rhs_class_name - @join_table = nil - end - - def join_table - @join_table ||= [@lhs_class.table_name, klass.table_name].sort.join("\0").gsub(/^(.*[._])(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_") - end - - private - - def klass - @lhs_class.send(:compute_type, @rhs_class_name) - end - end - - def self.build(lhs_class, name, options) - if options[:join_table] - KnownTable.new options[:join_table].to_s - else - class_name = options.fetch(:class_name) { - name.to_s.camelize.singularize - } - KnownClass.new lhs_class, class_name.to_s - end - end - end - attr_reader :lhs_model, :association_name, :options def initialize(association_name, lhs_model, options) @@ -42,10 +11,8 @@ module ActiveRecord::Associations::Builder # :nodoc: end def through_model - habtm = JoinTableResolver.build lhs_model, association_name, options - join_model = Class.new(ActiveRecord::Base) { - class << self; + class << self attr_accessor :left_model attr_accessor :name attr_accessor :table_name_resolver @@ -54,7 +21,9 @@ module ActiveRecord::Associations::Builder # :nodoc: end def self.table_name - table_name_resolver.join_table + # Table name needs to be resolved lazily + # because RHS class might not have been loaded + @table_name ||= table_name_resolver.call end def self.compute_type(class_name) @@ -84,7 +53,7 @@ module ActiveRecord::Associations::Builder # :nodoc: } join_model.name = "HABTM_#{association_name.to_s.camelize}" - join_model.table_name_resolver = habtm + join_model.table_name_resolver = -> { table_name } join_model.left_model = lhs_model join_model.add_left_association :left_side, anonymous_class: lhs_model @@ -115,6 +84,18 @@ module ActiveRecord::Associations::Builder # :nodoc: middle_options end + def table_name + if options[:join_table] + options[:join_table].to_s + else + class_name = options.fetch(:class_name) { + association_name.to_s.camelize.singularize + } + klass = lhs_model.send(:compute_type, class_name.to_s) + [lhs_model.table_name, klass.table_name].sort.join("\0").gsub(/^(.*[._])(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_") + end + end + def belongs_to_options(options) rhs_options = {} diff --git a/activerecord/lib/active_record/associations/builder/has_many.rb b/activerecord/lib/active_record/associations/builder/has_many.rb index 7864d4c536..5b9617bc6d 100644 --- a/activerecord/lib/active_record/associations/builder/has_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_many.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord::Associations::Builder # :nodoc: class HasMany < CollectionAssociation #:nodoc: def self.macro diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb index 4de846d12b..bfb37d6eee 100644 --- a/activerecord/lib/active_record/associations/builder/has_one.rb +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord::Associations::Builder # :nodoc: class HasOne < SingularAssociation #:nodoc: def self.macro diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb index 7732b63af6..0a02ef4cc1 100644 --- a/activerecord/lib/active_record/associations/builder/singular_association.rb +++ b/activerecord/lib/active_record/associations/builder/singular_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This class is inherited by the has_one and belongs_to association classes module ActiveRecord::Associations::Builder # :nodoc: diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index 77282e6463..840d900bbc 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Associations # = Active Record Association Collection @@ -30,7 +32,8 @@ module ActiveRecord reload end - CollectionProxy.create(klass, self) + @proxy ||= CollectionProxy.create(klass, self) + @proxy.reset_scope end # Implements the writer method, e.g. foo.items= for Foo.has_many :items @@ -42,24 +45,28 @@ module ActiveRecord def ids_reader if loaded? target.pluck(reflection.association_primary_key) + elsif !target.empty? + load_target.pluck(reflection.association_primary_key) else - @association_ids ||= ( - column = "#{reflection.quoted_table_name}.#{reflection.association_primary_key}" - scope.pluck(column) - ) + @association_ids ||= scope.pluck(reflection.association_primary_key) end end # Implements the ids writer method, e.g. foo.item_ids= for Foo.has_many :items def ids_writer(ids) - pk_type = reflection.association_primary_key_type + primary_key = reflection.association_primary_key + pk_type = klass.type_for_attribute(primary_key) ids = Array(ids).reject(&:blank?) ids.map! { |i| pk_type.cast(i) } - records = klass.where(reflection.association_primary_key => ids).index_by do |r| - r.send(reflection.association_primary_key) + + records = klass.where(primary_key => ids).index_by do |r| + r.public_send(primary_key) end.values_at(*ids).compact + if records.size != ids.size - klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, reflection.association_primary_key) + found_ids = records.map { |record| record.public_send(primary_key) } + not_found_ids = ids - found_ids + klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, primary_key, not_found_ids) else replace(records) end @@ -68,26 +75,29 @@ module ActiveRecord def reset super @target = [] + @association_ids = nil end def find(*args) - if block_given? - load_target.find(*args) { |*block_args| yield(*block_args) } - else - if options[:inverse_of] && loaded? - args_flatten = args.flatten - raise RecordNotFound, "Couldn't find #{scope.klass.name} without an ID" if args_flatten.blank? - result = find_by_scan(*args) - - result_size = Array(result).size - if !result || result_size != args_flatten.size - scope.raise_record_not_found_exception!(args_flatten, result_size, args_flatten.size) - else - result - end + if options[:inverse_of] && loaded? + args_flatten = args.flatten + model = scope.klass + + if args_flatten.blank? + error_message = "Couldn't find #{model.name} without an ID" + raise RecordNotFound.new(error_message, model.name, model.primary_key, args) + end + + result = find_by_scan(*args) + + result_size = Array(result).size + if !result || result_size != args_flatten.size + scope.raise_record_not_found_exception!(args_flatten, result_size, args_flatten.size) else - scope.find(*args) + result end + else + scope.find(*args) end end @@ -95,9 +105,7 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| build(attr, &block) } else - add_to_target(build_record(attributes)) do |record| - yield(record) if block_given? - end + add_to_target(build_record(attributes, &block)) end end @@ -179,8 +187,6 @@ module ActiveRecord # are actually removed from the database, that depends precisely on # +delete_records+. They are in any case removed from the collection. def delete(*records) - return if records.empty? - records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) } delete_or_destroy(records, options[:dependent]) end @@ -190,8 +196,6 @@ module ActiveRecord # Note that this method removes records from the database ignoring the # +:dependent+ option. def destroy(*records) - return if records.empty? - records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) } delete_or_destroy(records, :destroy) end @@ -208,9 +212,11 @@ module ActiveRecord def size if !find_target? || loaded? target.size + elsif @association_ids + @association_ids.size elsif !association_scope.group_values.empty? load_target.size - elsif !association_scope.distinct_value && target.is_a?(Array) + elsif !association_scope.distinct_value && !target.empty? unsaved_records = target.select(&:new_record?) unsaved_records.size + count_records else @@ -227,10 +233,10 @@ module ActiveRecord # loaded and you are going to fetch the records anyway it is better to # check <tt>collection.length.zero?</tt>. def empty? - if loaded? + if loaded? || @association_ids size.zero? else - @target.blank? && !scope.exists? + target.empty? && !scope.exists? end end @@ -280,35 +286,6 @@ module ActiveRecord replace_on_target(record, index, skip_callbacks, &block) end - def replace_on_target(record, index, skip_callbacks) - callback(:before_add, record) unless skip_callbacks - - begin - if index - record_was = target[index] - target[index] = record - else - target << record - end - - set_inverse_instance(record) - - yield(record) if block_given? - rescue - if index - target[index] = record_was - else - target.delete(record) - end - - raise - end - - callback(:after_add, record) unless skip_callbacks - - record - end - def scope scope = super scope.none! if null_scope? @@ -328,18 +305,17 @@ module ActiveRecord private def find_target - return scope.to_a if skip_statement_cache? + scope = self.scope + return scope.to_a if skip_statement_cache?(scope) conn = klass.connection - sc = reflection.association_scope_cache(conn, owner) do - StatementCache.create(conn) { |params| - as = AssociationScope.create { params.bind } - target_scope.merge as.scope(self, conn) - } + sc = reflection.association_scope_cache(conn, owner) do |params| + as = AssociationScope.create { params.bind } + target_scope.merge!(as.scope(self)) end binds = AssociationScope.get_bind_values(owner, reflection.chain) - sc.execute(binds, klass, conn) do |record| + sc.execute(binds, conn) do |record| set_inverse_instance(record) end end @@ -382,25 +358,33 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| _create_record(attr, raise, &block) } else + record = build_record(attributes, &block) transaction do - add_to_target(build_record(attributes)) do |record| - yield(record) if block_given? - insert_record(record, true, raise) + result = nil + add_to_target(record) do + result = insert_record(record, true, raise) { + @_was_loaded = loaded? + @association_ids = nil + } end + raise ActiveRecord::Rollback unless result end + record end end # Do the relevant stuff to insert the given record into the association collection. - def insert_record(record, validate = true, raise = false) - raise NotImplementedError - end - - def create_scope - scope.scope_for_create.stringify_keys + def insert_record(record, validate = true, raise = false, &block) + if raise + record.save!(validate: validate, &block) + else + record.save(validate: validate, &block) + end end def delete_or_destroy(records, method) + return if records.empty? + records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) } records = records.flatten records.each { |record| raise_on_type_mismatch!(record) } existing_records = records.reject(&:new_record?) @@ -416,7 +400,7 @@ module ActiveRecord records.each { |record| callback(:before_remove, record) } delete_records(existing_records, method) if existing_records.any? - records.each { |record| target.delete(record) } + @target -= records records.each { |record| callback(:after_remove, record) } end @@ -448,17 +432,46 @@ module ActiveRecord end end - def concat_records(records, should_raise = false) + def concat_records(records, raise = false) result = true records.each do |record| raise_on_type_mismatch!(record) - add_to_target(record) do |rec| - result &&= insert_record(rec, true, should_raise) unless owner.new_record? + add_to_target(record) do + unless owner.new_record? + result &&= insert_record(record, true, raise) { + @_was_loaded = loaded? + @association_ids = nil + } + end end end - result && records + raise ActiveRecord::Rollback unless result + + records + end + + def replace_on_target(record, index, skip_callbacks) + callback(:before_add, record) unless skip_callbacks + + set_inverse_instance(record) + + @_was_loaded = true + + yield(record) if block_given? + + if index + target[index] = record + elsif @_was_loaded || !loaded? + target << record + end + + callback(:after_add, record) unless skip_callbacks + + record + ensure + @_was_loaded = nil end def callback(method, record) diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index 55bf2e0ff0..9a30198b95 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Associations # Association proxies in Active Record are middlemen between the object that @@ -30,7 +32,10 @@ module ActiveRecord class CollectionProxy < Relation def initialize(klass, association) #:nodoc: @association = association - super klass, klass.arel_table, klass.predicate_builder + super klass + + extensions = association.extensions + extend(*extensions) if extensions.any? end def target @@ -78,7 +83,7 @@ module ActiveRecord # # #<Pet id: nil, name: "Choo-Choo"> # # ] # - # person.pets.select(:id, :name ) + # person.pets.select(:id, :name) # # => [ # # #<Pet id: 1, name: "Fancy-Fancy">, # # #<Pet id: 2, name: "Spook">, @@ -130,8 +135,9 @@ module ActiveRecord # # #<Pet id: 2, name: "Spook", person_id: 1>, # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> # # ] - def find(*args, &block) - @association.find(*args, &block) + def find(*args) + return super if block_given? + @association.find(*args) end ## @@ -743,10 +749,6 @@ module ActiveRecord # # ] #-- - def uniq - load_target.uniq - end - def calculate(operation, column_name) null_scope? ? scope.calculate(operation, column_name) : super end @@ -986,6 +988,12 @@ module ActiveRecord load_target == other end + ## + # :method: to_ary + # + # :call-seq: + # to_ary() + # # Returns a new array of objects from the collection. If the collection # hasn't been loaded, it fetches the records from the database. # @@ -1019,10 +1027,6 @@ module ActiveRecord # # #<Pet id: 5, name: "Brain", person_id: 1>, # # #<Pet id: 6, name: "Boss", person_id: 1> # # ] - def to_ary - load_target.dup - end - alias_method :to_a, :to_ary def records # :nodoc: load_target @@ -1070,7 +1074,6 @@ module ActiveRecord end # Reloads the collection from the database. Returns +self+. - # Equivalent to <tt>collection(true)</tt>. # # class Person < ActiveRecord::Base # has_many :pets @@ -1084,13 +1087,9 @@ module ActiveRecord # # person.pets.reload # fetches pets from the database # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] - # - # person.pets(true) # fetches pets from the database - # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] def reload - @scope = nil proxy_association.reload - self + reset_scope end # Unloads the association. Returns +self+. @@ -1110,9 +1109,14 @@ module ActiveRecord # person.pets # fetches pets from the database # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] def reset - @scope = nil proxy_association.reset proxy_association.reset_scope + reset_scope + end + + def reset_scope # :nodoc: + @offsets = {} + @scope = nil self end @@ -1121,7 +1125,7 @@ module ActiveRecord SpawnMethods, ].flat_map { |klass| klass.public_instance_methods(false) - } - self.public_instance_methods(false) + [:scoping] + } - self.public_instance_methods(false) - [:select] + [:scoping] delegate(*delegate_methods, to: :scope) @@ -1148,18 +1152,6 @@ module ActiveRecord def exec_queries load_target end - - def respond_to_missing?(method, _) - scope.respond_to?(method) || super - end - - def method_missing(method, *args, &block) - if scope.respond_to?(method) - scope.public_send(method, *args, &block) - else - super - end - end end end end diff --git a/activerecord/lib/active_record/associations/foreign_association.rb b/activerecord/lib/active_record/associations/foreign_association.rb index 3ceec0ee46..40010cde03 100644 --- a/activerecord/lib/active_record/associations/foreign_association.rb +++ b/activerecord/lib/active_record/associations/foreign_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord::Associations module ForeignAssociation # :nodoc: def foreign_key_present? diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index b413eb3f9c..cf85a87fa7 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Has Many Association module Associations + # = Active Record Has Many Association # This is the proxy that handles a has many association. # # If the association has a <tt>:through</tt> option further specialization @@ -31,12 +33,7 @@ module ActiveRecord def insert_record(record, validate = true, raise = false) set_owner_attributes(record) - - if raise - record.save!(validate: validate) - else - record.save(validate: validate) - end + super end def empty? @@ -66,7 +63,7 @@ module ActiveRecord count = if reflection.has_cached_counter? owner._read_attribute(reflection.counter_cache_column).to_i else - scope.count + scope.count(:all) end # If there's nothing in the database and @target has no new records diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index c4a7fe4432..617956c768 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -1,14 +1,14 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Has Many Through Association module Associations + # = Active Record Has Many Through Association class HasManyThroughAssociation < HasManyAssociation #:nodoc: include ThroughAssociation def initialize(owner, reflection) super - - @through_records = {} - @through_association = nil + @through_records = {} end def concat(*records) @@ -39,11 +39,7 @@ module ActiveRecord ensure_not_nested if record.new_record? || record.has_changes_to_save? - if raise - record.save!(validate: validate) - else - return unless record.save(validate: validate) - end + return unless super end save_through_record(record) @@ -52,11 +48,6 @@ module ActiveRecord end private - - def through_association - @through_association ||= owner.association(through_reflection.name) - end - # The through record (built with build_record) is temporarily cached # so that it may be reused if insert_record is subsequently called. # @@ -99,7 +90,7 @@ module ActiveRecord def build_record(attributes) ensure_not_nested - record = super(attributes) + record = super inverse = source_reflection.inverse_of if inverse @@ -113,6 +104,11 @@ module ActiveRecord record end + def remove_records(existing_records, records, method) + super + delete_through_records(records) + end + def target_reflection_has_associated_record? !(through_reflection.belongs_to? && owner[through_reflection.foreign_key].blank?) end @@ -137,21 +133,15 @@ module ActiveRecord scope = through_association.scope scope.where! construct_join_attributes(*records) + scope = scope.where(through_scope_attributes) case method when :destroy if scope.klass.primary_key - count = scope.destroy_all.length + count = scope.destroy_all.count(&:destroyed?) else scope.each(&:_run_destroy_callbacks) - - arel = scope.arel - - stmt = Arel::DeleteManager.new - stmt.from scope.klass.arel_table - stmt.wheres = arel.constraints - - count = scope.klass.connection.delete(stmt, "SQL", scope.bound_attributes) + count = scope.delete_all end when :nullify count = scope.update_all(source_reflection.foreign_key => nil) diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index 8458253ff8..390bfd8b08 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Has One Association module Associations + # = Active Record Has One Association class HasOneAssociation < SingularAssociation #:nodoc: include ForeignAssociation @@ -21,42 +23,15 @@ module ActiveRecord end end - def replace(record, save = true) - raise_on_type_mismatch!(record) if record - load_target - - return target unless target || record - - assigning_another_record = target != record - if assigning_another_record || record.has_changes_to_save? - save &&= owner.persisted? - - transaction_if(save) do - remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record - - if record - set_owner_attributes(record) - set_inverse_instance(record) - - if save && !record.save - nullify_owner_attributes(record) - set_owner_attributes(target) if target - raise RecordNotSaved, "Failed to save the new associated #{reflection.name}." - end - end - end - end - - self.target = record - end - def delete(method = options[:dependent]) if load_target case method when :delete target.delete when :destroy + target.destroyed_by_association = reflection target.destroy + throw(:abort) unless target.destroyed? when :nullify target.update_columns(reflection.foreign_key => nil) if target.persisted? end @@ -64,6 +39,33 @@ module ActiveRecord end private + def replace(record, save = true) + raise_on_type_mismatch!(record) if record + + return target unless load_target || record + + assigning_another_record = target != record + if assigning_another_record || record.has_changes_to_save? + save &&= owner.persisted? + + transaction_if(save) do + remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record + + if record + set_owner_attributes(record) + set_inverse_instance(record) + + if save && !record.save + nullify_owner_attributes(record) + set_owner_attributes(target) if target + raise RecordNotSaved, "Failed to save the new associated #{reflection.name}." + end + end + end + end + + self.target = record + end # The reason that the save param for replace is false, if for create (not just build), # is because the setting of the foreign keys is actually handled by the scoping when @@ -78,6 +80,7 @@ module ActiveRecord when :delete target.delete when :destroy + target.destroyed_by_association = reflection target.destroy else nullify_owner_attributes(target) @@ -102,6 +105,14 @@ module ActiveRecord yield end end + + def _create_record(attributes, raise_error = false, &block) + unless owner.persisted? + raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" + end + + super + end end end end diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index 1183bdf6f4..10978b2d93 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -1,20 +1,21 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Has One Through Association module Associations + # = Active Record Has One Through Association class HasOneThroughAssociation < HasOneAssociation #:nodoc: include ThroughAssociation - def replace(record) - create_through_record(record) - self.target = record - end - private + def replace(record, save = true) + create_through_record(record, save) + self.target = record + end - def create_through_record(record) + def create_through_record(record, save) ensure_not_nested - through_proxy = owner.association(through_reflection.name) + through_proxy = through_association through_record = through_proxy.load_target if through_record && !record @@ -27,8 +28,12 @@ module ActiveRecord end if through_record - through_record.update(attributes) - elsif owner.new_record? + if through_record.new_record? + through_record.assign_attributes(attributes) + else + through_record.update(attributes) + end + elsif owner.new_record? || !save through_proxy.build(attributes) else through_proxy.create(attributes) diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index 87e0847ec1..b76005b587 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Associations class JoinDependency # :nodoc: @@ -12,10 +14,8 @@ module ActiveRecord i[column.name] = column.alias } } - @name_and_alias_cache = tables.each_with_object({}) { |table, h| - h[table.node] = table.columns.map { |column| - [column.name, column.alias] - } + @columns_cache = tables.each_with_object({}) { |table, h| + h[table.node] = table.columns } end @@ -23,9 +23,8 @@ module ActiveRecord @tables.flat_map(&:column_aliases) end - # An array of [column_name, alias] pairs for the table def column_aliases(node) - @name_and_alias_cache[node] + @columns_cache[node] end def column_alias(node, column) @@ -33,20 +32,14 @@ module ActiveRecord end Table = Struct.new(:node, :columns) do # :nodoc: - def table - Arel::Nodes::TableAlias.new node.table, node.aliased_table_name - end - def column_aliases - t = table + t = node.table columns.map { |column| t[column.name].as Arel.sql column.alias } end end Column = Struct.new(:name, :alias) end - attr_reader :alias_tracker, :base_klass, :join_root - def self.make_tree(associations) hash = {} walk_tree associations, hash @@ -71,70 +64,32 @@ module ActiveRecord end end - # base is the base class on which operation is taking place. - # associations is the list of associations which are joined using hash, symbol or array. - # joins is the list of all string join commands and arel nodes. - # - # Example : - # - # class Physician < ActiveRecord::Base - # has_many :appointments - # has_many :patients, through: :appointments - # end - # - # If I execute `@physician.patients.to_a` then - # base # => Physician - # associations # => [] - # joins # => [#<Arel::Nodes::InnerJoin: ...] - # - # However if I execute `Physician.joins(:appointments).to_a` then - # base # => Physician - # associations # => [:appointments] - # joins # => [] - # - def initialize(base, associations, joins, eager_loading: true) - @alias_tracker = AliasTracker.create_with_joins(base.connection, base.table_name, joins, base.type_caster) - @eager_loading = eager_loading + def initialize(base, table, associations) tree = self.class.make_tree associations - @join_root = JoinBase.new base, build(tree, base) - @join_root.children.each { |child| construct_tables! @join_root, child } + @join_root = JoinBase.new(base, table, build(tree, base)) end def reflections join_root.drop(1).map!(&:reflection) end - def join_constraints(outer_joins, join_type) - joins = join_root.children.flat_map { |child| + def join_constraints(joins_to_add, join_type, alias_tracker) + @alias_tracker = alias_tracker - if join_type == Arel::Nodes::OuterJoin - make_left_outer_joins join_root, child - else - make_inner_joins join_root, child - end - } + construct_tables!(join_root) + joins = make_join_constraints(join_root, join_type) - joins.concat outer_joins.flat_map { |oj| + joins.concat joins_to_add.flat_map { |oj| + construct_tables!(oj.join_root) if join_root.match? oj.join_root walk join_root, oj.join_root else - oj.join_root.children.flat_map { |child| - make_outer_joins oj.join_root, child - } + make_join_constraints(oj.join_root, join_type) end } end - def aliases - Aliases.new join_root.each_with_index.map { |join_part, i| - columns = join_part.column_names.each_with_index.map { |column_name, j| - Aliases::Column.new column_name, "t#{i}_r#{j}" - } - Aliases::Table.new(join_part, columns) - } - end - - def instantiate(result_set, aliases) + def instantiate(result_set, &block) primary_key = aliases.column_alias(join_root, join_root.primary_key) seen = Hash.new { |i, object_id| @@ -157,65 +112,65 @@ module ActiveRecord message_bus.instrument("instantiation.active_record", payload) do result_set.each { |row_hash| parent_key = primary_key ? row_hash[primary_key] : row_hash - parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases) - construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases) + parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases, &block) + construct(parent, join_root, row_hash, seen, model_cache) } end parents.values end - private + def apply_column_aliases(relation) + relation._select!(-> { aliases.columns }) + end - def make_constraints(parent, child, tables, join_type) - chain = child.reflection.chain - foreign_table = parent.table - foreign_klass = parent.base_klass - child.join_constraints(foreign_table, foreign_klass, child, join_type, tables, chain) - end + protected + attr_reader :join_root - def make_outer_joins(parent, child) - tables = table_aliases_for(parent, child) - join_type = Arel::Nodes::OuterJoin - info = make_constraints parent, child, tables, join_type + private + attr_reader :alias_tracker - [info] + child.children.flat_map { |c| make_outer_joins(child, c) } + def aliases + @aliases ||= Aliases.new join_root.each_with_index.map { |join_part, i| + columns = join_part.column_names.each_with_index.map { |column_name, j| + Aliases::Column.new column_name, "t#{i}_r#{j}" + } + Aliases::Table.new(join_part, columns) + } end - def make_left_outer_joins(parent, child) - tables = child.tables - join_type = Arel::Nodes::OuterJoin - info = make_constraints parent, child, tables, join_type - - [info] + child.children.flat_map { |c| make_left_outer_joins(child, c) } + def construct_tables!(join_root) + join_root.each_children do |parent, child| + child.tables = table_aliases_for(parent, child) + end end - def make_inner_joins(parent, child) - tables = child.tables - join_type = Arel::Nodes::InnerJoin - info = make_constraints parent, child, tables, join_type + def make_join_constraints(join_root, join_type) + join_root.children.flat_map do |child| + make_constraints(join_root, child, join_type) + end + end - [info] + child.children.flat_map { |c| make_inner_joins(child, c) } + def make_constraints(parent, child, join_type = Arel::Nodes::OuterJoin) + foreign_table = parent.table + foreign_klass = parent.base_klass + joins = child.join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) + joins.concat child.children.flat_map { |c| make_constraints(child, c, join_type) } end def table_aliases_for(parent, node) node.reflection.chain.map { |reflection| alias_tracker.aliased_table_for( reflection.table_name, - table_alias_for(reflection, parent, reflection != node.reflection) + table_alias_for(reflection, parent, reflection != node.reflection), + reflection.klass.type_caster ) } end - def construct_tables!(parent, node) - node.tables = table_aliases_for(parent, node) - node.children.each { |child| construct_tables! node, child } - end - def table_alias_for(reflection, parent, join) - name = "#{reflection.plural_name}_#{parent.table_name}" - name << "_join" if join - name + name = reflection.alias_candidate(parent.table_name) + join ? "#{name}_join" : name end def walk(left, right) @@ -223,8 +178,8 @@ module ActiveRecord [left.children.find { |node2| node1.match? node2 }, node1] }.partition(&:first) - ojs = missing.flat_map { |_, n| make_outer_joins left, n } - intersection.flat_map { |l, r| walk l, r }.concat ojs + joins = intersection.flat_map { |l, r| r.table = l.table; walk(l, r) } + joins.concat missing.flat_map { |_, n| make_constraints(left, n) } end def find_reflection(klass, name) @@ -239,15 +194,14 @@ module ActiveRecord reflection.check_eager_loadable! if reflection.polymorphic? - next unless @eager_loading raise EagerLoadPolymorphicError.new(reflection) end - JoinAssociation.new reflection, build(right, reflection.klass) - end.compact + JoinAssociation.new(reflection, build(right, reflection.klass)) + end end - def construct(ar_parent, parent, row, rs, seen, model_cache, aliases) + def construct(ar_parent, parent, row, seen, model_cache) return if ar_parent.nil? parent.children.each do |node| @@ -256,7 +210,7 @@ module ActiveRecord other.loaded! elsif ar_parent.association_cached?(node.reflection.name) model = ar_parent.association(node.reflection.name).target - construct(model, node, row, rs, seen, model_cache, aliases) + construct(model, node, row, seen, model_cache) next end @@ -268,24 +222,20 @@ module ActiveRecord next end - model = seen[ar_parent.object_id][node.base_klass][id] + model = seen[ar_parent.object_id][node][id] if model - construct(model, node, row, rs, seen, model_cache, aliases) + construct(model, node, row, seen, model_cache) else - model = construct_model(ar_parent, node, row, model_cache, id, aliases) - - if node.reflection.scope_for(node.base_klass).readonly_value - model.readonly! - end + model = construct_model(ar_parent, node, row, model_cache, id) - seen[ar_parent.object_id][node.base_klass][id] = model - construct(model, node, row, rs, seen, model_cache, aliases) + seen[ar_parent.object_id][node][id] = model + construct(model, node, row, seen, model_cache) end end end - def construct_model(record, node, row, model_cache, id, aliases) + def construct_model(record, node, row, model_cache, id) other = record.association(node.reflection.name) model = model_cache[node][id] ||= @@ -299,6 +249,7 @@ module ActiveRecord other.target = model end + model.readonly! if node.readonly? model end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index f5fcba1236..4583d89cba 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -1,19 +1,19 @@ +# frozen_string_literal: true + require "active_record/associations/join_dependency/join_part" module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinAssociation < JoinPart # :nodoc: - # The reflection of the association represented - attr_reader :reflection - - attr_accessor :tables + attr_reader :reflection, :tables + attr_accessor :table def initialize(reflection, children) super(reflection.klass, children) - @reflection = reflection - @tables = nil + @reflection = reflection + @tables = nil end def match?(other) @@ -21,110 +21,44 @@ module ActiveRecord super && reflection == other.reflection end - JoinInformation = Struct.new :joins, :binds - - def join_constraints(foreign_table, foreign_klass, node, join_type, tables, chain) - joins = [] - binds = [] - tables = tables.reverse + def join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) + joins = [] # The chain starts with the target table, but we want to end with it here (makes # more sense in this context), so we reverse - chain.reverse_each do |reflection| - table = tables.shift + reflection.chain.reverse_each.with_index(1) do |reflection, i| + table = tables[-i] klass = reflection.klass - join_keys = reflection.join_keys(klass) - key = join_keys.key - foreign_key = join_keys.foreign_key - - constraint = build_constraint(klass, table, key, foreign_table, foreign_key) - - predicate_builder = PredicateBuilder.new(TableMetadata.new(klass, table)) - scope_chain_items = reflection.scopes.map do |item| - if item.is_a?(Relation) - item - else - ActiveRecord::Relation.create(klass, table, predicate_builder) - .instance_exec(node, &item) - end - end - - klass_scope = - if klass.current_scope - klass.current_scope.clone.tap { |scope| - scope.joins_values = [] - } - else - relation = ActiveRecord::Relation.create( - klass, - table, - predicate_builder, - ) - klass.send(:build_default_scope, relation) - end - scope_chain_items.concat [klass_scope].compact - - rel = scope_chain_items.inject(scope_chain_items.shift) do |left, right| - left.merge right - end + constraint = reflection.build_join_constraint(table, foreign_table) - if rel && !rel.arel.constraints.empty? - binds += rel.bound_attributes - constraint = constraint.and rel.arel.constraints - end + joins << table.create_join(table, table.create_on(constraint), join_type) - if reflection.type - value = foreign_klass.base_class.name - column = klass.columns_hash[reflection.type.to_s] + join_scope = reflection.join_scope(table, foreign_klass) + arel = join_scope.arel(alias_tracker.aliases) - binds << Relation::QueryAttribute.new(column.name, value, klass.type_for_attribute(column.name)) - constraint = constraint.and klass.arel_attribute(reflection.type, table).eq(Arel::Nodes::BindParam.new) + if arel.constraints.any? + joins.concat arel.join_sources + right = joins.last.right + right.expr = right.expr.and(arel.constraints) end - joins << table.create_join(table, table.create_on(constraint), join_type) - # The current table in this iteration becomes the foreign table in the next foreign_table, foreign_klass = table, klass end - JoinInformation.new joins, binds + joins end - # Builds equality condition. - # - # Example: - # - # class Physician < ActiveRecord::Base - # has_many :appointments - # end - # - # If I execute `Physician.joins(:appointments).to_a` then - # klass # => Physician - # table # => #<Arel::Table @name="appointments" ...> - # key # => physician_id - # foreign_table # => #<Arel::Table @name="physicians" ...> - # foreign_key # => id - # - def build_constraint(klass, table, key, foreign_table, foreign_key) - constraint = table[key].eq(foreign_table[foreign_key]) - - if klass.finder_needs_type_condition? - constraint = table.create_and([ - constraint, - klass.send(:type_condition, table) - ]) - end - - constraint + def tables=(tables) + @tables = tables + @table = tables.first end - def table - tables.first - end + def readonly? + return @readonly if defined?(@readonly) - def aliased_table_name - table.table_alias || table.name + @readonly = reflection.scope && reflection.scope_for(base_klass.unscoped).readonly_value end end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_base.rb b/activerecord/lib/active_record/associations/join_dependency/join_base.rb index fca20514d1..988b4e8fa2 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_base.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_base.rb @@ -1,20 +1,21 @@ +# frozen_string_literal: true + require "active_record/associations/join_dependency/join_part" module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinBase < JoinPart # :nodoc: - def match?(other) - return true if self == other - super && base_klass == other.base_klass - end + attr_reader :table - def table - base_klass.arel_table + def initialize(base_klass, table, children) + super(base_klass, children) + @table = table end - def aliased_table_name - base_klass.table_name + def match?(other) + return true if self == other + super && base_klass == other.base_klass end end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_part.rb b/activerecord/lib/active_record/associations/join_dependency/join_part.rb index 61cec5403a..3ad72a3646 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_part.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_part.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Associations class JoinDependency # :nodoc: @@ -22,10 +24,6 @@ module ActiveRecord @children = children end - def name - reflection.name - end - def match?(other) self.class == other.class end @@ -35,13 +33,15 @@ module ActiveRecord children.each { |child| child.each(&block) } end - # An Arel::Table for the active_record - def table - raise NotImplementedError + def each_children(&block) + children.each do |child| + yield self, child + child.each_children(&block) + end end - # The alias for the active_record's table - def aliased_table_name + # An Arel::Table for the active_record + def table raise NotImplementedError end @@ -54,8 +54,8 @@ module ActiveRecord length = column_names_with_alias.length while index < length - column_name, alias_name = column_names_with_alias[index] - hash[column_name] = row[alias_name] + column = column_names_with_alias[index] + hash[column.name] = row[column.alias] index += 1 end diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 9f77f38b35..5c2ac5b374 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Associations # Implements the details of eager loading of Active Record associations. @@ -42,20 +44,10 @@ module ActiveRecord extend ActiveSupport::Autoload eager_autoload do - autoload :Association, "active_record/associations/preloader/association" - autoload :SingularAssociation, "active_record/associations/preloader/singular_association" - autoload :CollectionAssociation, "active_record/associations/preloader/collection_association" - autoload :ThroughAssociation, "active_record/associations/preloader/through_association" - - autoload :HasMany, "active_record/associations/preloader/has_many" - autoload :HasManyThrough, "active_record/associations/preloader/has_many_through" - autoload :HasOne, "active_record/associations/preloader/has_one" - autoload :HasOneThrough, "active_record/associations/preloader/has_one_through" - autoload :BelongsTo, "active_record/associations/preloader/belongs_to" + autoload :Association, "active_record/associations/preloader/association" + autoload :ThroughAssociation, "active_record/associations/preloader/through_association" end - NULL_RELATION = Struct.new(:values, :where_clause, :joins_values).new({}, Relation::WhereClause.empty, []) - # Eager loads the named associations for the given Active Record record(s). # # In this description, 'association name' shall refer to the name passed @@ -91,14 +83,13 @@ module ActiveRecord # { author: :avatar } # [ :books, { author: :avatar } ] def preload(records, associations, preload_scope = nil) - records = Array.wrap(records).compact.uniq - associations = Array.wrap(associations) - preload_scope = preload_scope || NULL_RELATION + records = Array.wrap(records).compact if records.empty? [] else - associations.flat_map { |association| + records.uniq! + Array.wrap(associations).flat_map { |association| preloaders_on association, records, preload_scope } end @@ -107,26 +98,30 @@ module ActiveRecord private # Loads all the given data into +records+ for the +association+. - def preloaders_on(association, records, scope) + def preloaders_on(association, records, scope, polymorphic_parent = false) case association when Hash - preloaders_for_hash(association, records, scope) + preloaders_for_hash(association, records, scope, polymorphic_parent) when Symbol - preloaders_for_one(association, records, scope) + preloaders_for_one(association, records, scope, polymorphic_parent) when String - preloaders_for_one(association.to_sym, records, scope) + preloaders_for_one(association.to_sym, records, scope, polymorphic_parent) else raise ArgumentError, "#{association.inspect} was not recognized for preload" end end - def preloaders_for_hash(association, records, scope) + def preloaders_for_hash(association, records, scope, polymorphic_parent) association.flat_map { |parent, child| - loaders = preloaders_for_one parent, records, scope + loaders = preloaders_for_one parent, records, scope, polymorphic_parent recs = loaders.flat_map(&:preloaded_records).uniq + + reflection = records.first.class._reflect_on_association(parent) + polymorphic_parent = reflection && reflection.options[:polymorphic] + loaders.concat Array.wrap(child).flat_map { |assoc| - preloaders_on assoc, recs, scope + preloaders_on assoc, recs, scope, polymorphic_parent } loaders } @@ -144,21 +139,23 @@ module ActiveRecord # Additionally, polymorphic belongs_to associations can have multiple associated # classes, depending on the polymorphic_type field. So we group by the classes as # well. - def preloaders_for_one(association, records, scope) - grouped_records(association, records).flat_map do |reflection, klasses| + def preloaders_for_one(association, records, scope, polymorphic_parent) + grouped_records(association, records, polymorphic_parent).flat_map do |reflection, klasses| klasses.map do |rhs_klass, rs| - loader = preloader_for(reflection, rs, rhs_klass).new(rhs_klass, rs, reflection, scope) + loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) loader.run self loader end end end - def grouped_records(association, records) + def grouped_records(association, records, polymorphic_parent) h = {} records.each do |record| next unless record + next if polymorphic_parent && !record.class._reflect_on_association(association) assoc = record.association(association) + next unless assoc.klass klasses = h[assoc.reflection] ||= {} (klasses[assoc.klass] ||= []) << record end @@ -166,8 +163,6 @@ module ActiveRecord end class AlreadyLoaded # :nodoc: - attr_reader :owners, :reflection - def initialize(klass, owners, reflection, preload_scope) @owners = owners @reflection = reflection @@ -178,34 +173,24 @@ module ActiveRecord def preloaded_records owners.flat_map { |owner| owner.association(reflection.name).target } end - end - class NullPreloader # :nodoc: - def self.new(klass, owners, reflection, preload_scope); self; end - def self.run(preloader); end - def self.preloaded_records; []; end - def self.owners; []; end + private + attr_reader :owners, :reflection end # Returns a class containing the logic needed to load preload the data - # and attach it to a relation. For example +Preloader::Association+ or - # +Preloader::HasManyThrough+. The class returned implements a `run` method + # and attach it to a relation. The class returned implements a `run` method # that accepts a preloader. - def preloader_for(reflection, owners, rhs_klass) - return NullPreloader unless rhs_klass - + def preloader_for(reflection, owners) if owners.first.association(reflection.name).loaded? return AlreadyLoaded end reflection.check_preloadable! - case reflection.macro - when :has_many - reflection.options[:through] ? HasManyThrough : HasMany - when :has_one - reflection.options[:through] ? HasOneThrough : HasOne - when :belongs_to - BelongsTo + if reflection.options[:through] + ThroughAssociation + else + Association end end end diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index 4072d19380..d6f7359055 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true + module ActiveRecord module Associations class Preloader class Association #:nodoc: - attr_reader :owners, :reflection, :preload_scope, :model, :klass attr_reader :preloaded_records def initialize(klass, owners, reflection, preload_scope) @@ -11,86 +12,64 @@ module ActiveRecord @reflection = reflection @preload_scope = preload_scope @model = owners.first && owners.first.class - @scope = nil @preloaded_records = [] end def run(preloader) - preload(preloader) - end - - def preload(preloader) - raise NotImplementedError - end - - def scope - @scope ||= build_scope - end - - def records_for(ids) - scope.where(association_key_name => ids) - end - - def table - klass.arel_table - end - - # The name of the key on the associated records - def association_key_name - raise NotImplementedError - end - - # This is overridden by HABTM as the condition should be on the foreign_key column in - # the join table - def association_key - klass.arel_attribute(association_key_name, table) - end - - # The name of the key on the model which declares the association - def owner_key_name - raise NotImplementedError - end + records = load_records do |record| + owner = owners_by_key[convert_key(record[association_key_name])] + association = owner.association(reflection.name) + association.set_inverse_instance(record) + end - def options - reflection.options + owners.each do |owner| + associate_records_to_owner(owner, records[convert_key(owner[owner_key_name])] || []) + end end private + attr_reader :owners, :reflection, :preload_scope, :model, :klass - def associated_records_by_owner(preloader) - records = load_records do |record| - owner = owners_by_key[convert_key(record[association_key_name])] - association = owner.association(reflection.name) - association.set_inverse_instance(record) - end + # The name of the key on the associated records + def association_key_name + reflection.join_primary_key(klass) + end + + # The name of the key on the model which declares the association + def owner_key_name + reflection.join_foreign_key + end - owners.each_with_object({}) do |owner, result| - result[owner] = records[convert_key(owner[owner_key_name])] || [] + def associate_records_to_owner(owner, records) + association = owner.association(reflection.name) + association.loaded! + if reflection.collection? + association.target.concat(records) + else + association.target = records.first unless records.empty? end end def owner_keys - unless defined?(@owner_keys) - @owner_keys = owners.map do |owner| - owner[owner_key_name] - end - @owner_keys.uniq! - @owner_keys.compact! - end - @owner_keys + @owner_keys ||= owners_by_key.keys end def owners_by_key unless defined?(@owners_by_key) @owners_by_key = owners.each_with_object({}) do |owner, h| - h[convert_key(owner[owner_key_name])] = owner + key = convert_key(owner[owner_key_name]) + h[key] = owner if key end end @owners_by_key end def key_conversion_required? - @key_conversion_required ||= association_key_type != owner_key_type + unless defined?(@key_conversion_required) + @key_conversion_required = (association_key_type != owner_key_type) + end + + @key_conversion_required end def convert_key(key) @@ -102,11 +81,11 @@ module ActiveRecord end def association_key_type - @klass.type_for_attribute(association_key_name.to_s).type + @klass.type_for_attribute(association_key_name).type end def owner_key_type - @model.type_for_attribute(owner_key_name.to_s).type + @model.type_for_attribute(owner_key_name).type end def load_records(&block) @@ -115,54 +94,35 @@ module ActiveRecord # 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).load(&block) + records_for(slice, &block) end @preloaded_records.group_by do |record| convert_key(record[association_key_name]) end end - def reflection_scope - @reflection_scope ||= reflection.scope_for(klass) + def records_for(ids, &block) + scope.where(association_key_name => ids).load(&block) end - def build_scope - scope = klass.unscoped - - values = reflection_scope.values - preload_values = preload_scope.values - - scope.where_clause = reflection_scope.where_clause + preload_scope.where_clause - scope.references_values = Array(values[:references]) + Array(preload_values[:references]) - - if preload_values[:select] || values[:select] - scope._select!(preload_values[:select] || values[:select]) - end - scope.includes! preload_values[:includes] || values[:includes] - if preload_scope.joins_values.any? - scope.joins!(preload_scope.joins_values) - else - scope.joins!(reflection_scope.joins_values) - end - - if order_values = preload_values[:order] || values[:order] - scope.order!(order_values) - end + def scope + @scope ||= build_scope + end - if preload_values[:reordering] || values[:reordering] - scope.reordering_value = true - end + def reflection_scope + @reflection_scope ||= reflection.scope ? reflection.scope_for(klass.unscoped) : klass.unscoped + end - if preload_values[:readonly] || values[:readonly] - scope.readonly! - end + def build_scope + scope = klass.scope_for_association - if options[:as] - scope.where!(klass.table_name => { reflection.type => model.base_class.sti_name }) + if reflection.type + scope.where!(reflection.type => model.polymorphic_name) end - scope.unscope_values = Array(values[:unscope]) + Array(preload_values[:unscope]) - klass.default_scoped.merge(scope) + scope.merge!(reflection_scope) if reflection.scope + scope.merge!(preload_scope) if preload_scope + scope end end end diff --git a/activerecord/lib/active_record/associations/preloader/belongs_to.rb b/activerecord/lib/active_record/associations/preloader/belongs_to.rb deleted file mode 100644 index 38e231826c..0000000000 --- a/activerecord/lib/active_record/associations/preloader/belongs_to.rb +++ /dev/null @@ -1,15 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class BelongsTo < SingularAssociation #:nodoc: - def association_key_name - reflection.options[:primary_key] || klass && klass.primary_key - end - - def owner_key_name - reflection.foreign_key - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/collection_association.rb b/activerecord/lib/active_record/associations/preloader/collection_association.rb deleted file mode 100644 index 26690bf16d..0000000000 --- a/activerecord/lib/active_record/associations/preloader/collection_association.rb +++ /dev/null @@ -1,17 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class CollectionAssociation < Association #:nodoc: - private - - def preload(preloader) - associated_records_by_owner(preloader).each do |owner, records| - association = owner.association(reflection.name) - association.loaded! - association.target.concat(records) - end - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_many.rb b/activerecord/lib/active_record/associations/preloader/has_many.rb deleted file mode 100644 index 20df1cc19a..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_many.rb +++ /dev/null @@ -1,15 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class HasMany < CollectionAssociation #:nodoc: - def association_key_name - reflection.foreign_key - end - - def owner_key_name - reflection.active_record_primary_key - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_many_through.rb b/activerecord/lib/active_record/associations/preloader/has_many_through.rb deleted file mode 100644 index 2029871f39..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_many_through.rb +++ /dev/null @@ -1,19 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class HasManyThrough < CollectionAssociation #:nodoc: - include ThroughAssociation - - def associated_records_by_owner(preloader) - records_by_owner = super - - if reflection_scope.distinct_value - records_by_owner.each_value(&:uniq!) - end - - records_by_owner - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_one.rb b/activerecord/lib/active_record/associations/preloader/has_one.rb deleted file mode 100644 index c4add621ca..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_one.rb +++ /dev/null @@ -1,15 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class HasOne < SingularAssociation #:nodoc: - def association_key_name - reflection.foreign_key - end - - def owner_key_name - reflection.active_record_primary_key - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_one_through.rb b/activerecord/lib/active_record/associations/preloader/has_one_through.rb deleted file mode 100644 index f063f85574..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_one_through.rb +++ /dev/null @@ -1,9 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class HasOneThrough < SingularAssociation #:nodoc: - include ThroughAssociation - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/singular_association.rb b/activerecord/lib/active_record/associations/preloader/singular_association.rb deleted file mode 100644 index 5c5828262e..0000000000 --- a/activerecord/lib/active_record/associations/preloader/singular_association.rb +++ /dev/null @@ -1,18 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class SingularAssociation < Association #:nodoc: - private - - def preload(preloader) - associated_records_by_owner(preloader).each do |owner, associated_records| - record = associated_records.first - - association = owner.association(reflection.name) - association.target = record - end - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index 9d44a02021..a6b7ab80a2 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -1,106 +1,105 @@ +# frozen_string_literal: true + module ActiveRecord module Associations class Preloader - module ThroughAssociation #:nodoc: - def through_reflection - reflection.through_reflection - end - - def source_reflection - reflection.source_reflection - end - - def associated_records_by_owner(preloader) - preloader.preload(owners, - through_reflection.name, - through_scope) - - through_records = owners.map do |owner| - association = owner.association through_reflection.name - - center = target_records_from_association(association) - [owner, Array(center)] - end - - reset_association owners, through_reflection.name - - middle_records = through_records.flat_map { |(_, rec)| rec } - - preloaders = preloader.preload(middle_records, - source_reflection.name, - reflection_scope) - + class ThroughAssociation < Association # :nodoc: + def run(preloader) + already_loaded = owners.first.association(through_reflection.name).loaded? + through_scope = through_scope() + reflection_scope = target_reflection_scope + through_preloaders = preloader.preload(owners, through_reflection.name, through_scope) + middle_records = through_preloaders.flat_map(&:preloaded_records) + preloaders = preloader.preload(middle_records, source_reflection.name, reflection_scope) @preloaded_records = preloaders.flat_map(&:preloaded_records) - middle_to_pl = preloaders.each_with_object({}) do |pl, h| - pl.owners.each { |middle| - h[middle] = pl - } - end - - through_records.each_with_object({}) do |(lhs, center), records_by_owner| - pl_to_middle = center.group_by { |record| middle_to_pl[record] } - - records_by_owner[lhs] = pl_to_middle.flat_map do |pl, middles| - rhs_records = middles.flat_map { |r| - association = r.association source_reflection.name - - target_records_from_association(association) - }.compact - - # Respect the order on `reflection_scope` if it exists, else use the natural order. - if reflection_scope.values[:order].present? - @id_map ||= id_to_index_map @preloaded_records - rhs_records.sort_by { |rhs| @id_map[rhs] } - else - rhs_records + owners.each do |owner| + through_records = Array(owner.association(through_reflection.name).target) + if already_loaded + if source_type = reflection.options[:source_type] + through_records = through_records.select do |record| + record[reflection.foreign_type] == source_type + end end + else + owner.association(through_reflection.name).reset if through_scope + end + result = through_records.flat_map do |record| + association = record.association(source_reflection.name) + target = association.target + association.reset if preload_scope + target + end + result.compact! + if reflection_scope + result.sort_by! { |rhs| preload_index[rhs] } if reflection_scope.order_values.any? + result.uniq! if reflection_scope.distinct_value end + associate_records_to_owner(owner, result) end end private - - def id_to_index_map(ids) - id_map = {} - ids.each_with_index { |id, index| id_map[id] = index } - id_map + def through_reflection + reflection.through_reflection end - def reset_association(owners, association_name) - should_reset = (through_scope != through_reflection.klass.unscoped) || - (reflection.options[:source_type] && through_reflection.collection?) + def source_reflection + reflection.source_reflection + end - # Don't cache the association - we would only be caching a subset - if should_reset - owners.each { |owner| - owner.association(association_name).reset - } + def preload_index + @preload_index ||= @preloaded_records.each_with_object({}).with_index do |(id, result), index| + result[id] = index end end def through_scope scope = through_reflection.klass.unscoped + options = reflection.options if options[:source_type] scope.where! reflection.foreign_type => options[:source_type] - else - unless reflection_scope.where_clause.empty? - scope.includes_values = Array(reflection_scope.values[:includes] || options[:source]) - scope.where_clause = reflection_scope.where_clause + elsif !reflection_scope.where_clause.empty? + scope.where_clause = reflection_scope.where_clause + values = reflection_scope.values + + if includes = values[:includes] + scope.includes!(source_reflection.name => includes) + else + scope.includes!(source_reflection.name) end - scope.references! reflection_scope.values[:references] - if scope.eager_loading? && order_values = reflection_scope.values[:order] + if values[:references] && !values[:references].empty? + scope.references!(values[:references]) + else + scope.references!(source_reflection.table_name) + end + + if joins = values[:joins] + scope.joins!(source_reflection.name => joins) + end + + if left_outer_joins = values[:left_outer_joins] + scope.left_outer_joins!(source_reflection.name => left_outer_joins) + end + + if scope.eager_loading? && order_values = values[:order] scope = scope.order(order_values) end end - scope + scope unless scope.empty_scope? end - def target_records_from_association(association) - association.loaded? ? association.target : association.reader + def target_reflection_scope + if preload_scope + reflection_scope.merge(preload_scope) + elsif reflection.scope + reflection_scope + else + nil + end end end end diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb index 91580a28d0..cfab16a745 100644 --- a/activerecord/lib/active_record/associations/singular_association.rb +++ b/activerecord/lib/active_record/associations/singular_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Associations class SingularAssociation < Association #:nodoc: @@ -15,9 +17,8 @@ module ActiveRecord replace(record) end - def build(attributes = {}) - record = build_record(attributes) - yield(record) if block_given? + def build(attributes = {}, &block) + record = build_record(attributes, &block) set_new_record(record) record end @@ -30,24 +31,22 @@ module ActiveRecord end private - - def create_scope - scope.scope_for_create.stringify_keys.except(klass.primary_key) + def scope_for_create + super.except!(klass.primary_key) end def find_target - return scope.take if skip_statement_cache? + scope = self.scope + return scope.take if skip_statement_cache?(scope) conn = klass.connection - sc = reflection.association_scope_cache(conn, owner) do - StatementCache.create(conn) { |params| - as = AssociationScope.create { params.bind } - target_scope.merge(as.scope(self, conn)).limit(1) - } + sc = reflection.association_scope_cache(conn, owner) do |params| + as = AssociationScope.create { params.bind } + target_scope.merge!(as.scope(self)).limit(1) end binds = AssociationScope.get_bind_values(owner, reflection.chain) - sc.execute(binds, klass, conn) do |record| + sc.execute(binds, conn) do |record| set_inverse_instance record end.first rescue ::RangeError @@ -62,9 +61,8 @@ module ActiveRecord replace(record) end - def _create_record(attributes, raise_error = false) - record = build_record(attributes) - yield(record) if block_given? + def _create_record(attributes, raise_error = false, &block) + record = build_record(attributes, &block) saved = record.save set_new_record(record) raise RecordInvalid.new(record) if !saved && raise_error diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 6b87993ba3..15e6565e69 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -1,10 +1,27 @@ +# frozen_string_literal: true + module ActiveRecord - # = Active Record Through Association module Associations + # = Active Record Through Association module ThroughAssociation #:nodoc: - delegate :source_reflection, :through_reflection, to: :reflection + delegate :source_reflection, to: :reflection private + def through_reflection + @through_reflection ||= begin + refl = reflection.through_reflection + + while refl.through_reflection? + refl = refl.through_reflection + end + + refl + end + end + + def through_association + @through_association ||= owner.association(through_reflection.name) + end # We merge in these scopes for two reasons: # @@ -13,7 +30,7 @@ module ActiveRecord def target_scope scope = super reflection.chain.drop(1).each do |reflection| - relation = reflection.klass.all + relation = reflection.klass.scope_for_association scope.merge!( relation.except(:select, :create_with, :includes, :preload, :joins, :eager_load) ) @@ -36,24 +53,22 @@ module ActiveRecord def construct_join_attributes(*records) ensure_mutable - if source_reflection.association_primary_key(reflection.klass) == reflection.klass.primary_key + association_primary_key = source_reflection.association_primary_key(reflection.klass) + + if association_primary_key == reflection.klass.primary_key && !options[:source_type] join_attributes = { source_reflection.name => records } else join_attributes = { - source_reflection.foreign_key => - records.map { |record| - record.send(source_reflection.association_primary_key(reflection.klass)) - } + source_reflection.foreign_key => records.map(&association_primary_key.to_sym) } end if options[:source_type] - join_attributes[source_reflection.foreign_type] = - records.map { |record| record.class.base_class.name } + join_attributes[source_reflection.foreign_type] = [ options[:source_type] ] end if records.count == 1 - Hash[join_attributes.map { |k, v| [k, v.first] }] + join_attributes.transform_values!(&:first) else join_attributes end @@ -99,7 +114,7 @@ module ActiveRecord attributes[inverse.foreign_key] = target.id end - super(attributes) + super end end end diff --git a/activerecord/lib/active_record/attribute.rb b/activerecord/lib/active_record/attribute.rb deleted file mode 100644 index 38281158d8..0000000000 --- a/activerecord/lib/active_record/attribute.rb +++ /dev/null @@ -1,240 +0,0 @@ -module ActiveRecord - class Attribute # :nodoc: - class << self - def from_database(name, value, type) - FromDatabase.new(name, value, type) - end - - def from_user(name, value, type, original_attribute = nil) - FromUser.new(name, value, type, original_attribute) - end - - def with_cast_value(name, value, type) - WithCastValue.new(name, value, type) - end - - def null(name) - Null.new(name) - end - - def uninitialized(name, type) - Uninitialized.new(name, type) - end - end - - attr_reader :name, :value_before_type_cast, :type - - # This method should not be called directly. - # Use #from_database or #from_user - def initialize(name, value_before_type_cast, type, original_attribute = nil) - @name = name - @value_before_type_cast = value_before_type_cast - @type = type - @original_attribute = original_attribute - end - - def value - # `defined?` is cheaper than `||=` when we get back falsy values - @value = type_cast(value_before_type_cast) unless defined?(@value) - @value - end - - def original_value - if assigned? - original_attribute.original_value - else - type_cast(value_before_type_cast) - end - end - - def value_for_database - type.serialize(value) - end - - def changed? - changed_from_assignment? || changed_in_place? - end - - def changed_in_place? - has_been_read? && type.changed_in_place?(original_value_for_database, value) - end - - def forgetting_assignment - with_value_from_database(value_for_database) - end - - def with_value_from_user(value) - type.assert_valid_value(value) - self.class.from_user(name, value, type, original_attribute || self) - end - - def with_value_from_database(value) - self.class.from_database(name, value, type) - end - - def with_cast_value(value) - self.class.with_cast_value(name, value, type) - end - - def with_type(type) - if changed_in_place? - with_value_from_user(value).with_type(type) - else - self.class.new(name, value_before_type_cast, type, original_attribute) - end - end - - def type_cast(*) - raise NotImplementedError - end - - def initialized? - true - end - - def came_from_user? - false - end - - def has_been_read? - defined?(@value) - end - - def ==(other) - self.class == other.class && - name == other.name && - value_before_type_cast == other.value_before_type_cast && - type == other.type - end - alias eql? == - - def hash - [self.class, name, value_before_type_cast, type].hash - end - - def init_with(coder) - @name = coder["name"] - @value_before_type_cast = coder["value_before_type_cast"] - @type = coder["type"] - @original_attribute = coder["original_attribute"] - @value = coder["value"] if coder.map.key?("value") - end - - def encode_with(coder) - coder["name"] = name - coder["value_before_type_cast"] = value_before_type_cast if value_before_type_cast - coder["type"] = type if type - coder["original_attribute"] = original_attribute if original_attribute - coder["value"] = value if defined?(@value) - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :original_attribute - alias_method :assigned?, :original_attribute - - def original_value_for_database - if assigned? - original_attribute.original_value_for_database - else - _original_value_for_database - end - end - - private - def initialize_dup(other) - if defined?(@value) && @value.duplicable? - @value = @value.dup - end - end - - def changed_from_assignment? - assigned? && type.changed?(original_value, value, value_before_type_cast) - end - - def _original_value_for_database - type.serialize(original_value) - end - - class FromDatabase < Attribute # :nodoc: - def type_cast(value) - type.deserialize(value) - end - - def _original_value_for_database - value_before_type_cast - end - end - - class FromUser < Attribute # :nodoc: - def type_cast(value) - type.cast(value) - end - - def came_from_user? - true - end - end - - class WithCastValue < Attribute # :nodoc: - def type_cast(value) - value - end - - def changed_in_place? - false - end - end - - class Null < Attribute # :nodoc: - def initialize(name) - super(name, nil, Type.default_value) - end - - def type_cast(*) - nil - end - - def with_type(type) - self.class.with_cast_value(name, nil, type) - end - - def with_value_from_database(value) - raise ActiveModel::MissingAttributeError, "can't write unknown attribute `#{name}`" - end - alias_method :with_value_from_user, :with_value_from_database - end - - class Uninitialized < Attribute # :nodoc: - UNINITIALIZED_ORIGINAL_VALUE = Object.new - - def initialize(name, type) - super(name, nil, type) - end - - def value - if block_given? - yield name - end - end - - def original_value - UNINITIALIZED_ORIGINAL_VALUE - end - - def value_for_database - end - - def initialized? - false - end - - def with_type(type) - self.class.new(name, type) - end - end - private_constant :FromDatabase, :FromUser, :Null, :Uninitialized, :WithCastValue - end -end diff --git a/activerecord/lib/active_record/attribute/user_provided_default.rb b/activerecord/lib/active_record/attribute/user_provided_default.rb deleted file mode 100644 index 57f8bbed76..0000000000 --- a/activerecord/lib/active_record/attribute/user_provided_default.rb +++ /dev/null @@ -1,30 +0,0 @@ -require "active_record/attribute" - -module ActiveRecord - class Attribute # :nodoc: - class UserProvidedDefault < FromUser # :nodoc: - def initialize(name, value, type, database_default) - @user_provided_value = value - super(name, value, type, database_default) - end - - def value_before_type_cast - if user_provided_value.is_a?(Proc) - @memoized_value_before_type_cast ||= user_provided_value.call - else - @user_provided_value - end - end - - def with_type(type) - self.class.new(name, user_provided_value, type, original_attribute) - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :user_provided_value - end - end -end diff --git a/activerecord/lib/active_record/attribute_assignment.rb b/activerecord/lib/active_record/attribute_assignment.rb index d0dfca0cac..b6f0e18764 100644 --- a/activerecord/lib/active_record/attribute_assignment.rb +++ b/activerecord/lib/active_record/attribute_assignment.rb @@ -1,15 +1,11 @@ +# frozen_string_literal: true + require "active_model/forbidden_attributes_protection" module ActiveRecord module AttributeAssignment - extend ActiveSupport::Concern include ActiveModel::AttributeAssignment - # Alias for ActiveModel::AttributeAssignment#assign_attributes. See ActiveModel::AttributeAssignment. - def attributes=(attributes) - assign_attributes(attributes) - end - private def _assign_attributes(attributes) diff --git a/activerecord/lib/active_record/attribute_decorators.rb b/activerecord/lib/active_record/attribute_decorators.rb index c39e9ce4c5..98b7805c0a 100644 --- a/activerecord/lib/active_record/attribute_decorators.rb +++ b/activerecord/lib/active_record/attribute_decorators.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeDecorators # :nodoc: extend ActiveSupport::Concern included do - class_attribute :attribute_type_decorations, instance_accessor: false # :internal: - self.attribute_type_decorations = TypeDecorator.new + class_attribute :attribute_type_decorations, instance_accessor: false, default: TypeDecorator.new # :internal: end module ClassMethods # :nodoc: diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index ebe06566cc..701f19a6ae 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -1,7 +1,6 @@ -require "active_support/core_ext/enumerable" -require "active_support/core_ext/string/filters" +# frozen_string_literal: true + require "mutex_m" -require "concurrent/map" module ActiveRecord # = Active Record Attribute Methods @@ -27,14 +26,16 @@ module ActiveRecord def self.set_name_cache(name, value) const_name = "ATTR_#{name}" unless const_defined? const_name - const_set const_name, value.dup.freeze + const_set const_name, -value end end } - BLACKLISTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) + RESTRICTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) - class GeneratedAttributeMethods < Module; end # :nodoc: + class GeneratedAttributeMethods < Module #:nodoc: + include Mutex_m + end module ClassMethods def inherited(child_class) #:nodoc: @@ -43,7 +44,7 @@ module ActiveRecord end def initialize_generated_modules # :nodoc: - @generated_attribute_methods = GeneratedAttributeMethods.new { extend Mutex_m } + @generated_attribute_methods = GeneratedAttributeMethods.new @attribute_methods_generated = false include @generated_attribute_methods @@ -58,11 +59,10 @@ module ActiveRecord # attribute methods. generated_attribute_methods.synchronize do return false if @attribute_methods_generated - superclass.define_attribute_methods unless self == base_class + superclass.define_attribute_methods unless base_class? super(attribute_names) @attribute_methods_generated = true end - true end def undefine_attribute_methods # :nodoc: @@ -123,7 +123,7 @@ module ActiveRecord # A class method is 'dangerous' if it is already (re)defined by Active Record, but # not by any ancestors. (So 'puts' is not dangerous but 'new' is.) def dangerous_class_method?(method_name) - BLACKLISTED_CLASS_METHODS.include?(method_name.to_s) || class_method_defined_within?(method_name, Base) + RESTRICTED_CLASS_METHODS.include?(method_name.to_s) || class_method_defined_within?(method_name, Base) end def class_method_defined_within?(name, klass, superklass = klass.superclass) # :nodoc: @@ -167,6 +167,59 @@ module ActiveRecord end end + # Regexp for column names (with or without a table name prefix). Matches + # the following: + # "#{table_name}.#{column_name}" + # "#{column_name}" + COLUMN_NAME = /\A(?:\w+\.)?\w+\z/i + + # Regexp for column names with order (with or without a table name + # prefix, with or without various order modifiers). Matches the following: + # "#{table_name}.#{column_name}" + # "#{table_name}.#{column_name} #{direction}" + # "#{table_name}.#{column_name} #{direction} NULLS FIRST" + # "#{table_name}.#{column_name} NULLS LAST" + # "#{column_name}" + # "#{column_name} #{direction}" + # "#{column_name} #{direction} NULLS FIRST" + # "#{column_name} NULLS LAST" + COLUMN_NAME_WITH_ORDER = / + \A + (?:\w+\.)? + \w+ + (?:\s+asc|\s+desc)? + (?:\s+nulls\s+(?:first|last))? + \z + /ix + + def disallow_raw_sql!(args, permit: COLUMN_NAME) # :nodoc: + unexpected = args.reject do |arg| + arg.kind_of?(Arel::Node) || + arg.is_a?(Arel::Nodes::SqlLiteral) || + arg.is_a?(Arel::Attributes::Attribute) || + arg.to_s.split(/\s*,\s*/).all? { |part| permit.match?(part) } + end + + return if unexpected.none? + + if allow_unsafe_raw_sql == :deprecated + ActiveSupport::Deprecation.warn( + "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 " \ + "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()." + ) + else + raise(ActiveRecord::UnknownAttributeReference, + "Query method called with non-attribute argument(s): " + + unexpected.map(&:inspect).join(", ") + ) + end + end + # Returns true if the given attribute exists, otherwise false. # # class Person < ActiveRecord::Base @@ -236,7 +289,7 @@ module ActiveRecord return has_attribute?(name) end - return true + true end # Returns +true+ if the given attribute is in the attributes hash, otherwise +false+. @@ -392,38 +445,22 @@ module ActiveRecord @attributes.accessed end - protected - - def attribute_method?(attr_name) # :nodoc: + private + def attribute_method?(attr_name) # We check defined? because Syck calls respond_to? before actually calling initialize. defined?(@attributes) && @attributes.key?(attr_name) end - private - - def arel_attributes_with_values_for_create(attribute_names) - arel_attributes_with_values(attributes_for_create(attribute_names)) - end - - def arel_attributes_with_values_for_update(attribute_names) - arel_attributes_with_values(attributes_for_update(attribute_names)) - end - - # Returns a Hash of the Arel::Attributes and attribute values that have been - # typecasted for use in an Arel insert/update method. - def arel_attributes_with_values(attribute_names) - attrs = {} - arel_table = self.class.arel_table - - attribute_names.each do |name| - attrs[arel_table[name]] = typecasted_attribute_value(name) + def attributes_with_values(attribute_names) + attribute_names.each_with_object({}) do |name, attrs| + attrs[name] = _read_attribute(name) end - attrs end # Filters the primary keys and readonly attributes from the attribute names. def attributes_for_update(attribute_names) - attribute_names.reject do |name| + attribute_names &= self.class.column_names + attribute_names.delete_if do |name| readonly_attribute?(name) end end @@ -431,7 +468,8 @@ module ActiveRecord # Filters out the primary keys, from the attribute names, when the primary # key is to be generated (e.g. the id attribute has no value). def attributes_for_create(attribute_names) - attribute_names.reject do |name| + attribute_names &= self.class.column_names + attribute_names.delete_if do |name| pk_attribute?(name) && id.nil? end end @@ -443,9 +481,5 @@ module ActiveRecord def pk_attribute?(name) name == self.class.primary_key end - - def typecasted_attribute_value(name) - _read_attribute(name) - 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 115eb1ef3f..5941f51a1a 100644 --- a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb +++ b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeMethods # = Active Record Attribute Methods Before Type Cast diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb index 6aa414ba6b..ebc2252c50 100644 --- a/activerecord/lib/active_record/attribute_methods/dirty.rb +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true + require "active_support/core_ext/module/attribute_accessors" -require "active_record/attribute_mutation_tracker" module ActiveRecord module AttributeMethods - module Dirty # :nodoc: + module Dirty extend ActiveSupport::Concern include ActiveModel::Dirty @@ -14,11 +14,7 @@ module ActiveRecord raise "You cannot include Dirty after Timestamp" end - class_attribute :partial_writes, instance_writer: false - self.partial_writes = true - - after_create { changes_internally_applied } - after_update { changes_internally_applied } + class_attribute :partial_writes, instance_writer: false, default: true # Attribute methods for "changed in last call to save?" attribute_method_affix(prefix: "saved_change_to_", suffix: "?") @@ -30,109 +26,22 @@ module ActiveRecord attribute_method_suffix("_change_to_be_saved", "_in_database") end - # Attempts to +save+ the record and clears changed attributes if successful. - def save(*) - if status = super - changes_applied - end - status - end - - # Attempts to <tt>save!</tt> the record and clears changed attributes if successful. - def save!(*) - super.tap do - changes_applied - end - end - # <tt>reload</tt> the record and clears changed attributes. def reload(*) super.tap do - @previous_mutation_tracker = nil - clear_mutation_trackers - @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new - end - end - - def initialize_dup(other) # :nodoc: - super - @attributes = self.class._default_attributes.map do |attr| - attr.with_value_from_user(@attributes.fetch_value(attr.name)) - end - clear_mutation_trackers - end - - def changes_internally_applied # :nodoc: - @mutations_before_last_save = mutation_tracker - forget_attribute_assignments - @mutations_from_database = AttributeMutationTracker.new(@attributes) - end - - def changes_applied - @previous_mutation_tracker = mutation_tracker - @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new - clear_mutation_trackers - end - - def clear_changes_information - @previous_mutation_tracker = nil - @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new - forget_attribute_assignments - clear_mutation_trackers - end - - def raw_write_attribute(attr_name, *) - result = super - clear_attribute_change(attr_name) - result - end - - def clear_attribute_changes(attr_names) - super - attr_names.each do |attr_name| - clear_attribute_change(attr_name) - end - end - - def changed_attributes - # This should only be set by methods which will call changed_attributes - # multiple times when it is known that the computed value cannot change. - if defined?(@cached_changed_attributes) - @cached_changed_attributes - else - emit_warning_if_needed("changed_attributes", "saved_changes.transform_values(&:first)") - super.reverse_merge(mutation_tracker.changed_values).freeze - end - end - - def changes - cache_changed_attributes do - emit_warning_if_needed("changes", "saved_changes") - super - end - end - - def previous_changes - unless previous_mutation_tracker.equal?(mutations_before_last_save) - ActiveSupport::Deprecation.warn(<<-EOW.strip_heredoc) - The behavior of `previous_changes` inside of after callbacks is - deprecated without replacement. In the next release of Rails, - this method inside of `after_save` will return the changes that - were just saved. - EOW + @previously_changed = ActiveSupport::HashWithIndifferentAccess.new + @mutations_before_last_save = nil + @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new + @mutations_from_database = nil end - previous_mutation_tracker.changes - end - - def attribute_changed_in_place?(attr_name) - mutation_tracker.changed_in_place?(attr_name) end - # Did this attribute change when we last saved? This method can be invoked - # as `saved_change_to_name?` instead of `saved_change_to_attribute?("name")`. - # Behaves similarly to +attribute_changed?+. This method is useful in - # after callbacks to determine if the call to save changed a certain - # attribute. + # Did this attribute change when we last saved? + # + # This method is useful in after callbacks to determine if an attribute + # was changed during the save that triggered the callbacks to run. It can + # be invoked as +saved_change_to_name?+ instead of + # <tt>saved_change_to_attribute?("name")</tt>. # # ==== Options # @@ -149,24 +58,25 @@ module ActiveRecord # attribute was changed, the result will be an array containing the # original value and the saved value. # - # Behaves similarly to +attribute_change+. This method is useful in after - # callbacks, to see the change in an attribute that just occurred - # - # This method can be invoked as `saved_change_to_name` in instead of - # `saved_change_to_attribute("name")` + # This method is useful in after callbacks, to see the change in an + # attribute during the save that triggered the callbacks to run. It can be + # 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) end # Returns the original value of an attribute before the last save. - # Behaves similarly to +attribute_was+. This method is useful in after - # callbacks to get the original value of an attribute before the save that - # just occurred + # + # This method is useful in after callbacks to get the original value of an + # attribute before the save that triggered the callbacks to run. It can be + # 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) end - # Did the last call to `save` have any changes to change? + # Did the last call to +save+ have any changes to change? def saved_changes? mutations_before_last_save.any_changes? end @@ -176,157 +86,98 @@ module ActiveRecord mutations_before_last_save.changes end - # Alias for `attribute_changed?` + # Will this attribute change the next time we save? + # + # This method is useful in validations and before callbacks to determine + # if the next call to +save+ will change a particular attribute. It can be + # invoked as +will_save_change_to_name?+ instead of + # <tt>will_save_change_to_attribute("name")</tt>. + # + # ==== Options + # + # +from+ When passed, this method will return false unless the original + # value is equal to the given option + # + # +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) end - # Alias for `attribute_change` + # Returns the change to an attribute that will be persisted during the + # next save. + # + # This method is useful in validations and before callbacks, to see the + # change to an attribute that will occur when the record is saved. It can + # be invoked as +name_change_to_be_saved+ instead of + # <tt>attribute_change_to_be_saved("name")</tt>. + # + # 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) end - # Alias for `attribute_was` + # Returns the value of an attribute in the database, as opposed to the + # in-memory value that will be persisted the next time the record is + # saved. + # + # This method is useful in validations and before callbacks, to see the + # original value of an attribute prior to any changes about to be + # 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) end - # Alias for `changed?` + # Will the next call to +save+ have any changes to persist? def has_changes_to_save? mutations_from_database.any_changes? end - # Alias for `changes` + # Returns a hash containing all the changes that will be persisted during + # the next save. def changes_to_save mutations_from_database.changes end - # Alias for `changed` + # Returns an array of the names of any attributes that will change when + # the record is next saved. def changed_attribute_names_to_save - changes_to_save.keys + mutations_from_database.changed_attribute_names end - # Alias for `changed_attributes` + # Returns a hash of the attributes that will change when the record is + # next saved. + # + # The hash keys are the attribute names, and the hash values are the + # original attribute values in the database (as opposed to the in-memory + # values about to be saved). def attributes_in_database - changes_to_save.transform_values(&:first) - end - - def attribute_was(*) - emit_warning_if_needed("attribute_was", "attribute_before_last_save") - super - end - - def attribute_change(*) - emit_warning_if_needed("attribute_change", "saved_change_to_attribute") - super - end - - def attribute_changed?(*) - emit_warning_if_needed("attribute_changed?", "saved_change_to_attribute?") - super - end - - def changed?(*) - emit_warning_if_needed("changed?", "saved_changes?") - super - end - - def changed(*) - emit_warning_if_needed("changed", "saved_changes.keys") - super + mutations_from_database.changed_values end private - - def mutation_tracker - unless defined?(@mutation_tracker) - @mutation_tracker = nil - end - @mutation_tracker ||= AttributeMutationTracker.new(@attributes) - end - - def emit_warning_if_needed(method_name, new_method_name) - unless mutation_tracker.equal?(mutations_from_database) - ActiveSupport::Deprecation.warn(<<-EOW.squish) - The behavior of `#{method_name}` inside of after callbacks will - be changing in the next version of Rails. The new return value will reflect the - behavior of calling the method after `save` returned (e.g. the opposite of what - it returns now). To maintain the current behavior, use `#{new_method_name}` - instead. - EOW - end - end - - def mutations_from_database - unless defined?(@mutations_from_database) - @mutations_from_database = nil - end - @mutations_from_database ||= mutation_tracker - end - - def changes_include?(attr_name) - super || mutation_tracker.changed?(attr_name) - end - - def clear_attribute_change(attr_name) - mutation_tracker.forget_change(attr_name) - mutations_from_database.forget_change(attr_name) - end - - def attribute_will_change!(attr_name) - super - if self.class.has_attribute?(attr_name) - mutations_from_database.force_change(attr_name) - else - ActiveSupport::Deprecation.warn(<<-EOW.squish) - #{attr_name} is not an attribute known to Active Record. - This behavior is deprecated and will be removed in the next - version of Rails. If you'd like #{attr_name} to be managed - by Active Record, add `attribute :#{attr_name} to your class. - EOW - mutations_from_database.deprecated_force_change(attr_name) - end - end - - def _update_record(*) - partial_writes? ? super(keys_for_partial_write) : super - end - - def _create_record(*) - partial_writes? ? super(keys_for_partial_write) : super - end - - def keys_for_partial_write - changed_attribute_names_to_save & self.class.column_names - end - - def forget_attribute_assignments - @attributes = @attributes.map(&:forgetting_assignment) - end - - def clear_mutation_trackers - @mutation_tracker = nil - @mutations_from_database = nil - @mutations_before_last_save = nil - end - - def previous_mutation_tracker - @previous_mutation_tracker ||= NullMutationTracker.instance + def write_attribute_without_type_cast(attr_name, _) + result = super + clear_attribute_change(attr_name) + result end - def mutations_before_last_save - @mutations_before_last_save ||= previous_mutation_tracker + def _update_record(attribute_names = attribute_names_for_partial_writes) + affected_rows = super + changes_applied + affected_rows end - def cache_changed_attributes - @cached_changed_attributes = changed_attributes - yield - ensure - clear_changed_attributes_cache + def _create_record(attribute_names = attribute_names_for_partial_writes) + id = super + changes_applied + id end - def clear_changed_attributes_cache - remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes) + def attribute_names_for_partial_writes + partial_writes? ? changed_attribute_names_to_save : attribute_names end end end diff --git a/activerecord/lib/active_record/attribute_methods/primary_key.rb b/activerecord/lib/active_record/attribute_methods/primary_key.rb index 2f32caa257..9b267bb7c0 100644 --- a/activerecord/lib/active_record/attribute_methods/primary_key.rb +++ b/activerecord/lib/active_record/attribute_methods/primary_key.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "set" module ActiveRecord @@ -8,23 +10,22 @@ module ActiveRecord # Returns this record's primary key value wrapped in an array if one is # available. def to_key - sync_with_transaction_state key = id [key] if key end # Returns the primary key value. def id - if pk = self.class.primary_key - sync_with_transaction_state - _read_attribute(pk) - end + sync_with_transaction_state + primary_key = self.class.primary_key + _read_attribute(primary_key) if primary_key end # Sets the primary key value. def id=(value) sync_with_transaction_state - write_attribute(self.class.primary_key, value) if self.class.primary_key + primary_key = self.class.primary_key + _write_attribute(primary_key, value) if primary_key end # Queries the primary key value. @@ -57,16 +58,12 @@ module ActiveRecord end module ClassMethods - def define_method_attribute(attr_name) - super + ID_ATTRIBUTE_METHODS = %w(id id= id? id_before_type_cast id_was id_in_database).to_set - if attr_name == primary_key && attr_name != "id" - generated_attribute_methods.send(:alias_method, :id, primary_key) - end + def instance_method_already_implemented?(method_name) + super || primary_key && ID_ATTRIBUTE_METHODS.include?(method_name) end - ID_ATTRIBUTE_METHODS = %w(id id= id? id_before_type_cast id_was id_in_database).to_set - def dangerous_attribute_method?(method_name) super && !ID_ATTRIBUTE_METHODS.include?(method_name) end @@ -86,7 +83,7 @@ module ActiveRecord end def reset_primary_key #:nodoc: - if self == base_class + if base_class? self.primary_key = get_primary_key(base_class.name) else self.primary_key = base_class.primary_key @@ -134,7 +131,7 @@ module ActiveRecord def suppress_composite_primary_key(pk) return pk unless pk.is_a?(Array) - warn <<-WARNING.strip_heredoc + warn <<~WARNING WARNING: Active Record does not support composite primary key. #{table_name} has composite primary key. Composite primary key is ignored. diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb index 10498f4322..6757e9b66a 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeMethods module Query diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index fdc4bf6621..0f7bcba564 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeMethods module Read extend ActiveSupport::Concern - module ClassMethods + module ClassMethods # :nodoc: private # We want to generate the methods via module_eval rather than @@ -25,13 +27,15 @@ module ActiveRecord # Making it frozen means that it doesn't get duped when used to # key the @attributes in read_attribute. def define_method_attribute(name) - safe_name = name.unpack("h*".freeze).first + safe_name = name.unpack1("h*".freeze) temp_method = "__temp__#{safe_name}" ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name + sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 def #{temp_method} + #{sync_with_transaction_state} name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name} _read_attribute(name) { |n| missing_attribute(n, caller) } end @@ -54,7 +58,9 @@ module ActiveRecord attr_name.to_s end - name = self.class.primary_key if name == "id".freeze && self.class.primary_key + primary_key = self.class.primary_key + name = primary_key if name == "id".freeze && primary_key + sync_with_transaction_state if name == primary_key _read_attribute(name, &block) end @@ -63,7 +69,7 @@ module ActiveRecord if defined?(JRUBY_VERSION) # This form is significantly faster on JRuby, and this is one of our biggest hotspots. # https://github.com/jruby/jruby/pull/2562 - def _read_attribute(attr_name, &block) # :nodoc + def _read_attribute(attr_name, &block) # :nodoc: @attributes.fetch_value(attr_name.to_s, &block) end else diff --git a/activerecord/lib/active_record/attribute_methods/serialization.rb b/activerecord/lib/active_record/attribute_methods/serialization.rb index 4d9aff76cc..6e0e90f39c 100644 --- a/activerecord/lib/active_record/attribute_methods/serialization.rb +++ b/activerecord/lib/active_record/attribute_methods/serialization.rb @@ -1,8 +1,20 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeMethods module Serialization extend ActiveSupport::Concern + class ColumnNotSerializableError < StandardError + def initialize(name, type) + super <<~EOS + Column `#{name}` of type #{type.class} does not support `serialize` feature. + Usually it means that you are trying to use `serialize` + on a column that already implements serialization natively. + EOS + end + end + module ClassMethods # If you have an attribute that needs to be saved to the database as an # object, and retrieved as the same object, then specify the name of that @@ -58,9 +70,20 @@ module ActiveRecord end decorate_attribute_type(attr_name, :serialize) do |type| + if type_incompatible_with_serialize?(type, class_name_or_coder) + raise ColumnNotSerializableError.new(attr_name, type) + end + Type::Serialized.new(type, coder) end end + + private + + def type_incompatible_with_serialize?(type, class_name) + type.is_a?(ActiveRecord::Type::Json) && class_name == ::JSON || + type.respond_to?(:type_cast_array, true) && class_name == ::Array + end end end end diff --git a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb index 321d039ed4..294a3dc32c 100644 --- a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeMethods module TimeZoneConversion @@ -54,17 +56,13 @@ module ActiveRecord extend ActiveSupport::Concern included do - mattr_accessor :time_zone_aware_attributes, instance_writer: false - self.time_zone_aware_attributes = false - - class_attribute :skip_time_zone_conversion_for_attributes, instance_writer: false - self.skip_time_zone_conversion_for_attributes = [] + mattr_accessor :time_zone_aware_attributes, instance_writer: false, default: false - class_attribute :time_zone_aware_types, instance_writer: false - self.time_zone_aware_types = [:datetime, :time] + class_attribute :skip_time_zone_conversion_for_attributes, instance_writer: false, default: [] + class_attribute :time_zone_aware_types, instance_writer: false, default: [ :datetime, :time ] end - module ClassMethods + module ClassMethods # :nodoc: private def inherited(subclass) @@ -75,7 +73,7 @@ module ActiveRecord # `skip_time_zone_conversion_for_attributes` would not be picked up. subclass.class_eval do matcher = ->(name, type) { create_time_zone_conversion_attribute?(name, type) } - decorate_matching_attribute_types(matcher, :_time_zone_conversion) do |type| + decorate_matching_attribute_types(matcher, "_time_zone_conversion") do |type| TimeZoneConverter.new(type) end end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index fe0e01db28..c7521422bb 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module AttributeMethods module Write @@ -7,17 +9,19 @@ module ActiveRecord attribute_method_suffix "=" end - module ClassMethods + module ClassMethods # :nodoc: private def define_method_attribute=(name) - safe_name = name.unpack("h*".freeze).first + safe_name = name.unpack1("h*".freeze) ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name + sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 def __temp__#{safe_name}=(value) name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name} - write_attribute(name, value) + #{sync_with_transaction_state} + _write_attribute(name, value) end alias_method #{(name + '=').inspect}, :__temp__#{safe_name}= undef_method :__temp__#{safe_name}= @@ -35,30 +39,29 @@ module ActiveRecord attr_name.to_s end - write_attribute_with_type_cast(name, value, true) + primary_key = self.class.primary_key + name = primary_key if name == "id".freeze && primary_key + sync_with_transaction_state if name == primary_key + _write_attribute(name, value) end - def raw_write_attribute(attr_name, value) # :nodoc: - write_attribute_with_type_cast(attr_name, value, false) + # 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: + @attributes.write_from_user(attr_name.to_s, value) + value end private - # Handle *= for method_missing. - def attribute=(attribute_name, value) - write_attribute(attribute_name, value) + def write_attribute_without_type_cast(attr_name, value) + name = attr_name.to_s + @attributes.write_cast_value(name, value) + value end - def write_attribute_with_type_cast(attr_name, value, should_type_cast) - attr_name = attr_name.to_s - attr_name = self.class.primary_key if attr_name == "id" && self.class.primary_key - - if should_type_cast - @attributes.write_from_user(attr_name, value) - else - @attributes.write_cast_value(attr_name, value) - end - - value + # Handle *= for method_missing. + def attribute=(attribute_name, value) + _write_attribute(attribute_name, value) end end end diff --git a/activerecord/lib/active_record/attribute_mutation_tracker.rb b/activerecord/lib/active_record/attribute_mutation_tracker.rb deleted file mode 100644 index 4de993e169..0000000000 --- a/activerecord/lib/active_record/attribute_mutation_tracker.rb +++ /dev/null @@ -1,113 +0,0 @@ -module ActiveRecord - class AttributeMutationTracker # :nodoc: - OPTION_NOT_GIVEN = Object.new - - def initialize(attributes) - @attributes = attributes - @forced_changes = Set.new - @deprecated_forced_changes = Set.new - end - - def changed_values - attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| - if changed?(attr_name) - result[attr_name] = attributes[attr_name].original_value - end - end - end - - def changes - attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| - change = change_to_attribute(attr_name) - if change - result[attr_name] = change - end - end - end - - def change_to_attribute(attr_name) - if changed?(attr_name) - [attributes[attr_name].original_value, attributes.fetch_value(attr_name)] - end - end - - def any_changes? - attr_names.any? { |attr| changed?(attr) } || deprecated_forced_changes.any? - end - - def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) - attr_name = attr_name.to_s - forced_changes.include?(attr_name) || - attributes[attr_name].changed? && - (OPTION_NOT_GIVEN == from || attributes[attr_name].original_value == from) && - (OPTION_NOT_GIVEN == to || attributes[attr_name].value == to) - end - - def changed_in_place?(attr_name) - attributes[attr_name].changed_in_place? - end - - def forget_change(attr_name) - attr_name = attr_name.to_s - attributes[attr_name] = attributes[attr_name].forgetting_assignment - forced_changes.delete(attr_name) - end - - def original_value(attr_name) - attributes[attr_name].original_value - end - - def force_change(attr_name) - forced_changes << attr_name.to_s - end - - def deprecated_force_change(attr_name) - deprecated_forced_changes << attr_name.to_s - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :attributes, :forced_changes, :deprecated_forced_changes - - private - - def attr_names - attributes.keys - end - end - - class NullMutationTracker # :nodoc: - include Singleton - - def changed_values(*) - {} - end - - def changes(*) - {} - end - - def change_to_attribute(attr_name) - end - - def any_changes?(*) - false - end - - def changed?(*) - false - end - - def changed_in_place?(*) - false - end - - def forget_change(*) - end - - def original_value(*) - end - end -end diff --git a/activerecord/lib/active_record/attribute_set.rb b/activerecord/lib/active_record/attribute_set.rb deleted file mode 100644 index 66b278219a..0000000000 --- a/activerecord/lib/active_record/attribute_set.rb +++ /dev/null @@ -1,113 +0,0 @@ -require "active_record/attribute_set/builder" -require "active_record/attribute_set/yaml_encoder" - -module ActiveRecord - class AttributeSet # :nodoc: - delegate :each_value, :fetch, to: :attributes - - def initialize(attributes) - @attributes = attributes - end - - def [](name) - attributes[name] || Attribute.null(name) - end - - def []=(name, value) - attributes[name] = value - end - - def values_before_type_cast - attributes.transform_values(&:value_before_type_cast) - end - - def to_hash - initialized_attributes.transform_values(&:value) - end - alias_method :to_h, :to_hash - - def key?(name) - attributes.key?(name) && self[name].initialized? - end - - def keys - attributes.each_key.select { |name| self[name].initialized? } - end - - if defined?(JRUBY_VERSION) - # This form is significantly faster on JRuby, and this is one of our biggest hotspots. - # https://github.com/jruby/jruby/pull/2562 - def fetch_value(name, &block) - self[name].value(&block) - end - else - def fetch_value(name) - self[name].value { |n| yield n if block_given? } - end - end - - def write_from_database(name, value) - attributes[name] = self[name].with_value_from_database(value) - end - - def write_from_user(name, value) - attributes[name] = self[name].with_value_from_user(value) - end - - def write_cast_value(name, value) - attributes[name] = self[name].with_cast_value(value) - end - - def freeze - @attributes.freeze - super - end - - def deep_dup - dup.tap do |copy| - copy.instance_variable_set(:@attributes, attributes.deep_dup) - end - end - - def initialize_dup(_) - @attributes = attributes.dup - super - end - - def initialize_clone(_) - @attributes = attributes.clone - super - end - - def reset(key) - if key?(key) - write_from_database(key, nil) - end - end - - def accessed - attributes.select { |_, attr| attr.has_been_read? }.keys - end - - def map(&block) - new_attributes = attributes.transform_values(&block) - AttributeSet.new(new_attributes) - end - - def ==(other) - attributes == other.attributes - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :attributes - - private - - def initialized_attributes - attributes.select { |_, attr| attr.initialized? } - end - end -end diff --git a/activerecord/lib/active_record/attribute_set/builder.rb b/activerecord/lib/active_record/attribute_set/builder.rb deleted file mode 100644 index 2f624d32af..0000000000 --- a/activerecord/lib/active_record/attribute_set/builder.rb +++ /dev/null @@ -1,124 +0,0 @@ -require "active_record/attribute" - -module ActiveRecord - class AttributeSet # :nodoc: - class Builder # :nodoc: - attr_reader :types, :always_initialized, :default - - def initialize(types, always_initialized = nil, &default) - @types = types - @always_initialized = always_initialized - @default = default - end - - def build_from_database(values = {}, additional_types = {}) - if always_initialized && !values.key?(always_initialized) - values[always_initialized] = nil - end - - attributes = LazyAttributeHash.new(types, values, additional_types, &default) - AttributeSet.new(attributes) - end - end - end - - class LazyAttributeHash # :nodoc: - delegate :transform_values, :each_key, :each_value, :fetch, to: :materialize - - def initialize(types, values, additional_types, &default) - @types = types - @values = values - @additional_types = additional_types - @materialized = false - @delegate_hash = {} - @default = default || proc {} - end - - def key?(key) - delegate_hash.key?(key) || values.key?(key) || types.key?(key) - end - - def [](key) - delegate_hash[key] || assign_default_value(key) - end - - def []=(key, value) - if frozen? - raise RuntimeError, "Can't modify frozen hash" - end - delegate_hash[key] = value - end - - def deep_dup - dup.tap do |copy| - copy.instance_variable_set(:@delegate_hash, delegate_hash.transform_values(&:dup)) - end - end - - def initialize_dup(_) - @delegate_hash = Hash[delegate_hash] - super - end - - def select - keys = types.keys | values.keys | delegate_hash.keys - keys.each_with_object({}) do |key, hash| - attribute = self[key] - if yield(key, attribute) - hash[key] = attribute - end - end - end - - def ==(other) - if other.is_a?(LazyAttributeHash) - materialize == other.materialize - else - materialize == other - end - end - - def marshal_dump - materialize - end - - def marshal_load(delegate_hash) - @delegate_hash = delegate_hash - @types = {} - @values = {} - @additional_types = {} - @materialized = true - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :types, :values, :additional_types, :delegate_hash, :default - - def materialize - unless @materialized - values.each_key { |key| self[key] } - types.each_key { |key| self[key] } - unless frozen? - @materialized = true - end - end - delegate_hash - end - - private - - def assign_default_value(name) - type = additional_types.fetch(name, types[name]) - value_present = true - value = values.fetch(name) { value_present = false } - - if value_present - delegate_hash[name] = Attribute.from_database(name, value, type) - elsif types.key?(name) - delegate_hash[name] = default.call(name) || Attribute.uninitialized(name, type) - end - end - end -end diff --git a/activerecord/lib/active_record/attribute_set/yaml_encoder.rb b/activerecord/lib/active_record/attribute_set/yaml_encoder.rb deleted file mode 100644 index 899de14792..0000000000 --- a/activerecord/lib/active_record/attribute_set/yaml_encoder.rb +++ /dev/null @@ -1,41 +0,0 @@ -module ActiveRecord - class AttributeSet - # Attempts to do more intelligent YAML dumping of an - # ActiveRecord::AttributeSet to reduce the size of the resulting string - class YAMLEncoder # :nodoc: - def initialize(default_types) - @default_types = default_types - end - - def encode(attribute_set, coder) - coder["concise_attributes"] = attribute_set.each_value.map do |attr| - if attr.type.equal?(default_types[attr.name]) - attr.with_type(nil) - else - attr - end - end - end - - def decode(coder) - if coder["attributes"] - coder["attributes"] - else - attributes_hash = Hash[coder["concise_attributes"].map do |attr| - if attr.type.nil? - attr = attr.with_type(default_types[attr.name]) - end - [attr.name, attr] - end] - AttributeSet.new(attributes_hash) - end - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :default_types - end - end -end diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index 75f5ba3a96..35150889d9 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -1,4 +1,6 @@ -require "active_record/attribute/user_provided_default" +# frozen_string_literal: true + +require "active_model/attribute/user_provided_default" module ActiveRecord # See ActiveRecord::Attributes::ClassMethods for documentation @@ -6,8 +8,7 @@ module ActiveRecord extend ActiveSupport::Concern included do - class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false # :internal: - self.attributes_to_define_after_schema_loads = {} + class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false, default: {} # :internal: end module ClassMethods @@ -56,7 +57,7 @@ module ActiveRecord # store_listing = StoreListing.new(price_in_cents: '10.1') # # # before - # store_listing.price_in_cents # => BigDecimal.new(10.1) + # store_listing.price_in_cents # => BigDecimal(10.1) # # class StoreListing < ActiveRecord::Base # attribute :price_in_cents, :integer @@ -249,14 +250,14 @@ module ActiveRecord if value == NO_DEFAULT_PROVIDED default_attribute = _default_attributes[name].with_type(type) elsif from_user - default_attribute = Attribute::UserProvidedDefault.new( + default_attribute = ActiveModel::Attribute::UserProvidedDefault.new( name, value, type, _default_attributes.fetch(name.to_s) { nil }, ) else - default_attribute = Attribute.from_database(name, value, type) + default_attribute = ActiveModel::Attribute.from_database(name, value, type) end _default_attributes[name] = default_attribute end diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index 6bccbc06cd..783a8366ce 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record Autosave Association # @@ -140,8 +142,7 @@ module ActiveRecord included do Associations::Builder::Association.extensions << AssociationBuilderExtension - mattr_accessor :index_nested_attribute_errors, instance_writer: false - self.index_nested_attribute_errors = false + mattr_accessor :index_nested_attribute_errors, instance_writer: false, default: false end module ClassMethods # :nodoc: @@ -181,6 +182,7 @@ module ActiveRecord if reflection.collection? before_save :before_save_collection_association + after_save :after_save_collection_association define_non_cyclic_method(save_method) { save_collection_association(reflection) } # Doesn't use after_save as that would save associations added in after_create/after_update twice @@ -215,13 +217,7 @@ module ActiveRecord method = :validate_single_association end - define_non_cyclic_method(validation_method) do - send(method, reflection) - # TODO: remove the following line as soon as the return value of - # callbacks is ignored, that is, returning `false` does not - # display a deprecation warning or halts the callback chain. - true - end + define_non_cyclic_method(validation_method) { send(method, reflection) } validate validation_method after_validation :_ensure_no_duplicate_errors end @@ -368,7 +364,10 @@ module ActiveRecord # association whether or not the parent was a new record before saving. def before_save_collection_association @new_record_before_save = new_record? - true + end + + def after_save_collection_association + @new_record_before_save = false end # Saves any new associated records, or all loaded autosave associations if @@ -384,7 +383,7 @@ module ActiveRecord autosave = reflection.options[:autosave] # reconstruct the scope now that we know the owner's id - association.reset_scope if association.respond_to?(:reset_scope) + association.reset_scope if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave) if autosave @@ -401,8 +400,13 @@ module ActiveRecord if autosave != false && (@new_record_before_save || record.new_record?) if autosave saved = association.insert_record(record, false) - else - association.insert_record(record) unless reflection.nested? + elsif !reflection.nested? + association_saved = association.insert_record(record) + + if reflection.validate? + errors.add(reflection.name) unless association_saved + saved = association_saved + end end elsif autosave saved = record.save(validate: false) @@ -437,6 +441,9 @@ module ActiveRecord if (autosave && record.changed_for_autosave?) || new_record? || record_changed?(reflection, record, key) unless reflection.through_reflection record[reflection.foreign_key] = key + if inverse_reflection = reflection.inverse_of + record.association(inverse_reflection.name).loaded! + end end saved = record.save(validate: !autosave) diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index ac1aa2df45..db097cb930 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "yaml" require "active_support/benchmarkable" require "active_support/dependencies" @@ -7,7 +9,6 @@ require "active_support/core_ext/module/attribute_accessors" require "active_support/core_ext/array/extract_options" require "active_support/core_ext/hash/deep_merge" require "active_support/core_ext/hash/slice" -require "active_support/core_ext/hash/transform_values" require "active_support/core_ext/string/behavior" require "active_support/core_ext/kernel/singleton_class" require "active_support/core_ext/module/introspection" @@ -21,6 +22,7 @@ require "active_record/explain_subscriber" require "active_record/relation/delegation" require "active_record/attributes" require "active_record/type_caster" +require "active_record/database_configurations" module ActiveRecord #:nodoc: # = Active Record @@ -287,6 +289,7 @@ module ActiveRecord #:nodoc: extend Enum extend Delegation::DelegateCache extend CollectionCacheKey + extend Aggregations::ClassMethods include Core include Persistence @@ -312,7 +315,6 @@ module ActiveRecord #:nodoc: include ActiveModel::SecurePassword include AutosaveAssociation include NestedAttributes - include Aggregations include Transactions include TouchLater include NoTouching diff --git a/activerecord/lib/active_record/callbacks.rb b/activerecord/lib/active_record/callbacks.rb index eb44887e18..b6852bfc71 100644 --- a/activerecord/lib/active_record/callbacks.rb +++ b/activerecord/lib/active_record/callbacks.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record \Callbacks # @@ -73,21 +75,7 @@ module ActiveRecord # end # # Now, when <tt>Topic#destroy</tt> is run only +destroy_author+ is called. When <tt>Reply#destroy</tt> is - # run, both +destroy_author+ and +destroy_readers+ are called. Contrast this to the following situation - # where the +before_destroy+ method is overridden: - # - # class Topic < ActiveRecord::Base - # def before_destroy() destroy_author end - # end - # - # class Reply < Topic - # def before_destroy() destroy_readers end - # end - # - # In that case, <tt>Reply#destroy</tt> would only run +destroy_readers+ and _not_ +destroy_author+. - # So, use the callback macros when you want to ensure that a certain callback is called for the entire - # hierarchy, and use the regular overwritable methods when you want to leave it up to each descendant - # to decide whether they want to call +super+ and trigger the inherited callbacks. + # run, both +destroy_author+ and +destroy_readers+ are called. # # *IMPORTANT:* In order for inheritance to work for the callback queues, you must specify the # callbacks before specifying the associations. Otherwise, you might trigger the loading of a @@ -96,9 +84,9 @@ module ActiveRecord # == Types of callbacks # # There are four types of callbacks accepted by the callback macros: Method references (symbol), callback objects, - # inline methods (using a proc), and inline eval methods (using a string). Method references and callback objects + # inline methods (using a proc). Method references and callback objects # are the recommended approaches, inline methods using a proc are sometimes appropriate (such as for - # creating mix-ins), and inline eval methods are deprecated. + # creating mix-ins). # # The method reference callbacks work by specifying a protected or private method available in the object, like this: # @@ -140,7 +128,7 @@ module ActiveRecord # end # end # - # So you specify the object you want messaged on a given callback. When that callback is triggered, the object has + # So you specify the object you want to be messaged on a given callback. When that callback is triggered, the object has # a method by the name of the callback messaged. You can make these callbacks more flexible by passing in other # initialization data such as the name of the attribute to work with: # @@ -230,7 +218,7 @@ module ActiveRecord # # For example: # - # class Topic + # class Topic < ActiveRecord::Base # has_many :children # # after_save :log_children @@ -238,7 +226,7 @@ module ActiveRecord # # private # - # def log_chidren + # def log_children # # Child processing # end # @@ -255,7 +243,7 @@ module ActiveRecord # # For example: # - # class Topic + # class Topic < ActiveRecord::Base # has_many :children # # after_commit :log_children @@ -263,7 +251,7 @@ module ActiveRecord # # private # - # def log_chidren + # def log_children # # Child processing # end # @@ -330,6 +318,10 @@ module ActiveRecord _run_touch_callbacks { super } end + def increment!(*, touch: nil) # :nodoc: + touch ? _run_touch_callbacks { super } : super + end + private def create_or_update(*) diff --git a/activerecord/lib/active_record/coders/json.rb b/activerecord/lib/active_record/coders/json.rb index cb185a881e..a69b38487e 100644 --- a/activerecord/lib/active_record/coders/json.rb +++ b/activerecord/lib/active_record/coders/json.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Coders # :nodoc: class JSON # :nodoc: diff --git a/activerecord/lib/active_record/coders/yaml_column.rb b/activerecord/lib/active_record/coders/yaml_column.rb index 9c52a31b95..11559141c7 100644 --- a/activerecord/lib/active_record/coders/yaml_column.rb +++ b/activerecord/lib/active_record/coders/yaml_column.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "yaml" module ActiveRecord diff --git a/activerecord/lib/active_record/collection_cache_key.rb b/activerecord/lib/active_record/collection_cache_key.rb index 43784b70e3..61581b0451 100644 --- a/activerecord/lib/active_record/collection_cache_key.rb +++ b/activerecord/lib/active_record/collection_cache_key.rb @@ -1,23 +1,37 @@ +# frozen_string_literal: true + module ActiveRecord module CollectionCacheKey def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: - query_signature = Digest::MD5.hexdigest(collection.to_sql) + query_signature = ActiveSupport::Digest.hexdigest(collection.to_sql) key = "#{collection.model_name.cache_key}/query-#{query_signature}" - if collection.loaded? - size = collection.size + if collection.loaded? || collection.distinct_value + size = collection.records.size if size > 0 - timestamp = collection.max_by(×tamp_column).public_send(timestamp_column) + timestamp = collection.max_by(×tamp_column)._read_attribute(timestamp_column) end else - column_type = type_for_attribute(timestamp_column.to_s) - column = "#{connection.quote_table_name(collection.table_name)}.#{connection.quote_column_name(timestamp_column)}" + 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) + subquery_alias = "subquery_for_cache_key" + subquery_column = "#{subquery_alias}.#{timestamp_column}" + 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 - query = collection - .unscope(:select) - .select("COUNT(*) AS #{connection.quote_column_name("size")}", "MAX(#{column}) AS timestamp") - .unscope(:order) - result = connection.select_one(query) + result = connection.select_one(arel, nil) if result.blank? size = 0 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 3f2e86a98d..e977d36cb9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "thread" require "concurrent/map" require "monitor" @@ -61,15 +63,13 @@ module ActiveRecord # There are several connection-pooling-related options that you can add to # your database connection configuration: # - # * +pool+: number indicating size of connection pool (default 5) - # * +checkout_timeout+: number of seconds to block and wait for a connection - # before giving up and raising a timeout error (default 5 seconds). - # * +reaping_frequency+: frequency in seconds to periodically run the - # Reaper, which attempts to find and recover connections from dead - # threads, which can occur if a programmer forgets to close a - # connection at the end of a thread or a thread dies unexpectedly. - # Regardless of this setting, the Reaper will be invoked before every - # blocking wait. (Default +nil+, which means don't schedule the Reaper). + # * +pool+: maximum number of connections the pool may manage (default 5). + # * +idle_timeout+: number of seconds that a connection will be kept + # unused in the pool before it is automatically disconnected (default + # 300 seconds). Set this to zero to keep connections forever. + # * +checkout_timeout+: number of seconds to wait for a connection to + # become available before giving up and raising a timeout error (default + # 5 seconds). # #-- # Synchronization policy: @@ -80,11 +80,8 @@ module ActiveRecord # * private methods that require being called in a +synchronize+ blocks # are now explicitly documented class ConnectionPool - # Threadsafe, fair, FIFO queue. Meant to be used by ConnectionPool - # with which it shares a Monitor. But could be a generic Queue. - # - # The Queue in stdlib's 'thread' could replace this class except - # stdlib's doesn't support waiting with a timeout. + # Threadsafe, fair, LIFO queue. Meant to be used by ConnectionPool + # with which it shares a Monitor. class Queue def initialize(lock = Monitor.new) @lock = lock @@ -173,7 +170,7 @@ module ActiveRecord # Removes and returns the head of the queue if possible, or +nil+. def remove - @queue.shift + @queue.pop end # Remove and return the head the queue if the number of @@ -191,7 +188,9 @@ module ActiveRecord t0 = Time.now elapsed = 0 loop do - @cond.wait(timeout - elapsed) + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @cond.wait(timeout - elapsed) + end return remove if any? @@ -268,7 +267,7 @@ module ActiveRecord # Connections must be leased while holding the main pool mutex. This is # an internal subclass that also +.leases+ returned connections while # still in queue's critical section (queue synchronizes with the same - # +@lock+ as the main pool) so that a returned connection is already + # <tt>@lock</tt> as the main pool) so that a returned connection is already # leased and there is no need to re-enter synchronized block. class ConnectionLeasingQueue < Queue # :nodoc: include BiasableQueue @@ -281,12 +280,12 @@ module ActiveRecord end end - # Every +frequency+ seconds, the reaper will call +reap+ on +pool+. - # A reaper instantiated with a +nil+ frequency will never reap the - # connection pool. + # Every +frequency+ seconds, the reaper will call +reap+ and +flush+ on + # +pool+. A reaper instantiated with a zero frequency will never reap + # the connection pool. # - # Configure the frequency by setting "reaping_frequency" in your - # database yaml file. + # Configure the frequency by setting +reaping_frequency+ in your database + # yaml file (default 60 seconds). class Reaper attr_reader :pool, :frequency @@ -296,11 +295,12 @@ module ActiveRecord end def run - return unless frequency + return unless frequency && frequency > 0 Thread.new(frequency, pool) { |t, p| loop do sleep t p.reap + p.flush end } end @@ -324,8 +324,10 @@ module ActiveRecord @spec = spec @checkout_timeout = (spec.config[:checkout_timeout] && spec.config[:checkout_timeout].to_f) || 5 - @reaper = Reaper.new(self, (spec.config[:reaping_frequency] && spec.config[:reaping_frequency].to_f)) - @reaper.run + if @idle_timeout = spec.config.fetch(:idle_timeout, 300) + @idle_timeout = @idle_timeout.to_f + @idle_timeout = nil if @idle_timeout <= 0 + end # default max pool size to 5 @size = (spec.config[:pool] && spec.config[:pool].to_i) || 5 @@ -338,7 +340,7 @@ module ActiveRecord # then that +thread+ does indeed own that +conn+. However, an absence of a such # mapping does not mean that the +thread+ doesn't own the said connection. In # that case +conn.owner+ attr should be consulted. - # Access and modification of +@thread_cached_conns+ does not require + # Access and modification of <tt>@thread_cached_conns</tt> does not require # synchronization. @thread_cached_conns = Concurrent::Map.new(initial_capacity: @size) @@ -355,6 +357,12 @@ module ActiveRecord @available = ConnectionLeasingQueue.new self @lock_thread = false + + # +reaping_frequency+ is configurable mostly for historical reasons, but it could + # also be useful if someone wants a very low +idle_timeout+. + reaping_frequency = spec.config.fetch(:reaping_frequency, 60) + @reaper = Reaper.new(self, reaping_frequency && reaping_frequency.to_f) + @reaper.run end def lock_thread=(lock_thread) @@ -447,6 +455,21 @@ module ActiveRecord disconnect(false) end + # Discards all connections in the pool (even if they're currently + # leased!), along with the pool itself. Any further interaction with the + # pool (except #spec and #schema_cache) is undefined. + # + # See AbstractAdapter#discard! + def discard! # :nodoc: + synchronize do + return if @connections.nil? # already discarded + @connections.each do |conn| + conn.discard! + end + @connections = @available = @thread_cached_conns = nil + end + end + # Clears the cache which maps classes and re-connects connections that # require reloading. # @@ -506,14 +529,16 @@ module ActiveRecord # +conn+: an AbstractAdapter object, which was obtained by earlier by # calling #checkout on this pool. def checkin(conn) - synchronize do - remove_connection_from_thread_cache conn + conn.lock.synchronize do + synchronize do + remove_connection_from_thread_cache conn - conn._run_checkin_callbacks do - conn.expire - end + conn._run_checkin_callbacks do + conn.expire + end - @available.add conn + @available.add conn + end end end @@ -570,6 +595,35 @@ module ActiveRecord end end + # Disconnect all connections that have been idle for at least + # +minimum_idle+ seconds. Connections currently checked out, or that were + # checked in less than +minimum_idle+ seconds ago, are unaffected. + def flush(minimum_idle = @idle_timeout) + return if minimum_idle.nil? + + idle_connections = synchronize do + @connections.select do |conn| + !conn.in_use? && conn.seconds_idle >= minimum_idle + end.each do |conn| + conn.lease + + @available.delete conn + @connections.delete conn + end + end + + idle_connections.each do |conn| + conn.disconnect! + end + end + + # Disconnect all currently idle connections. Connections currently checked + # out are unaffected. + def flush! + reap + flush(-1) + end + def num_waiting_in_queue # :nodoc: @available.num_waiting end @@ -677,7 +731,7 @@ module ActiveRecord # this block can't be easily moved into attempt_to_checkout_all_existing_connections's # rescue block, because doing so would put it outside of synchronize section, without # being in a critical section thread_report might become inaccurate - msg = "could not obtain ownership of all database connections in #{checkout_timeout} seconds" + msg = "could not obtain ownership of all database connections in #{checkout_timeout} seconds".dup thread_report = [] @connections.each do |conn| @@ -732,10 +786,10 @@ module ActiveRecord # Implementation detail: the connection returned by +acquire_connection+ # will already be "+connection.lease+ -ed" to the current thread. def acquire_connection(checkout_timeout) - # NOTE: we rely on +@available.poll+ and +try_to_checkout_new_connection+ to + # NOTE: we rely on <tt>@available.poll</tt> and +try_to_checkout_new_connection+ to # +conn.lease+ the returned connection (and to do this in a +synchronized+ # section). This is not the cleanest implementation, as ideally we would - # <tt>synchronize { conn.lease }</tt> in this method, but by leaving it to +@available.poll+ + # <tt>synchronize { conn.lease }</tt> in this method, but by leaving it to <tt>@available.poll</tt> # and +try_to_checkout_new_connection+ we can piggyback on +synchronize+ sections # of the said methods and avoid an additional +synchronize+ overhead. if conn = @available.poll || try_to_checkout_new_connection @@ -759,7 +813,7 @@ module ActiveRecord end end - # If the pool is not at a +@size+ limit, establish new connection. Connecting + # If the pool is not at a <tt>@size</tt> limit, establish new connection. Connecting # to the DB is done outside main synchronized section. #-- # Implementation constraint: a newly established connection returned by this @@ -825,7 +879,7 @@ module ActiveRecord # end # # class Book < ActiveRecord::Base - # establish_connection "library_db" + # establish_connection :library_db # end # # class ScaryBook < Book @@ -857,15 +911,35 @@ module ActiveRecord # All Active Record models use this handler to determine the connection pool that they # should use. # - # The ConnectionHandler class is not coupled with the Active models, as it has no knowlodge + # The ConnectionHandler class is not coupled with the Active models, as it has no knowledge # about the model. The model needs to pass a specification name to the handler, - # in order to lookup the correct connection pool. + # in order to look up the correct connection pool. class ConnectionHandler + def self.unowned_pool_finalizer(pid_map) # :nodoc: + lambda do |_| + discard_unowned_pools(pid_map) + end + end + + def self.discard_unowned_pools(pid_map) # :nodoc: + pid_map.each do |pid, pools| + pools.values.compact.each(&:discard!) unless pid == Process.pid + end + end + def initialize # These caches are keyed by spec.name (ConnectionSpecification#name). @owner_to_pool = Concurrent::Map.new(initial_capacity: 2) do |h, k| + # Discard the parent's connection pools immediately; we have no need + # of them + ConnectionHandler.discard_unowned_pools(h) + h[k] = Concurrent::Map.new(initial_capacity: 2) end + + # Backup finalizer: if the forked child never needed a pool, the above + # early discard has not occurred + ObjectSpace.define_finalizer self, ConnectionHandler.unowned_pool_finalizer(@owner_to_pool) end def connection_pool_list @@ -919,6 +993,13 @@ module ActiveRecord connection_pool_list.each(&:disconnect!) end + # Disconnects all currently idle connections. + # + # See ConnectionPool#flush! for details. + def flush_idle_connections! + connection_pool_list.each(&:flush!) + end + # Locate the connection of the nearest super class. This can be an # active or defined connection: if it is the latter, it will be # opened and set as the active connection for the class it was defined @@ -926,16 +1007,14 @@ module ActiveRecord def retrieve_connection(spec_name) #:nodoc: pool = retrieve_connection_pool(spec_name) raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found." unless pool - conn = pool.connection - raise ConnectionNotEstablished, "No connection for '#{spec_name}' in connection pool" unless conn - conn + pool.connection end # Returns true if a connection that's accessible to this class has # already been opened. def connected?(spec_name) - conn = retrieve_connection_pool(spec_name) - conn && conn.connected? + pool = retrieve_connection_pool(spec_name) + pool && pool.connected? end # Remove the connection for this class. This will close the active 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 407e019326..ad148efcfe 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + module ActiveRecord module ConnectionAdapters # :nodoc: module DatabaseLimits @@ -10,11 +14,13 @@ module ActiveRecord def column_name_length 64 end + deprecate :column_name_length # Returns the maximum length of a table name. def table_name_length 64 end + deprecate :table_name_length # Returns the maximum allowed length for an index name. This # limit is enforced by \Rails and is less than or equal to @@ -34,16 +40,19 @@ module ActiveRecord def columns_per_table 1024 end + deprecate :columns_per_table # Returns the maximum number of indexes per table. def indexes_per_table 16 end + deprecate :indexes_per_table # Returns the maximum number of columns in a multicolumn index. def columns_per_multicolumn_index 16 end + deprecate :columns_per_multicolumn_index # Returns the maximum number of elements in an IN (x,y,z) clause. # +nil+ means no limit. @@ -55,11 +64,13 @@ module ActiveRecord def sql_query_length 1048575 end + deprecate :sql_query_length # Returns maximum number of joins in a single query. def joins_per_query 256 end + deprecate :joins_per_query end end end 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 769f488469..fdc9ffa688 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters # :nodoc: module DatabaseStatements @@ -7,30 +9,43 @@ module ActiveRecord end # Converts an arel AST to SQL - def to_sql(arel, binds = []) - if arel.respond_to?(:ast) - collected = visitor.accept(arel.ast, collector) - collected.compile(binds, self).freeze + def to_sql(arel_or_sql_string, binds = []) + sql, _ = to_sql_and_binds(arel_or_sql_string, binds) + sql + end + + def to_sql_and_binds(arel_or_sql_string, binds = []) # :nodoc: + if arel_or_sql_string.respond_to?(:ast) + unless binds.empty? + raise "Passing bind parameters with an arel AST is forbidden. " \ + "The values must be stored on the AST directly" + end + sql, binds = visitor.compile(arel_or_sql_string.ast, collector) + [sql.freeze, binds || []] else - arel.dup.freeze + [arel_or_sql_string.dup.freeze, binds] end end + private :to_sql_and_binds # This is used in the StatementCache object. It returns an object that # can be used to query the database repeatedly. def cacheable_query(klass, arel) # :nodoc: - collected = visitor.accept(arel.ast, collector) if prepared_statements - klass.query(collected.value) + sql, binds = visitor.compile(arel.ast, collector) + query = klass.query(sql) else - klass.partial_query(collected.value) + collector = PartialQueryCollector.new + parts, binds = visitor.compile(arel.ast, collector) + query = klass.partial_query(parts) end + [query, binds] end # Returns an ActiveRecord::Result instance. def select_all(arel, name = nil, binds = [], preparable: nil) - arel, binds = binds_from_relation arel, binds - sql = to_sql(arel, binds) + arel = arel_from_relation(arel) + sql, binds = to_sql_and_binds(arel, binds) if !prepared_statements || (arel.is_a?(String) && preparable.nil?) preparable = false else @@ -51,9 +66,7 @@ module ActiveRecord # Returns a single value from a record def select_value(arel, name = nil, binds = []) - if result = select_rows(arel, name, binds).first - result.first - end + single_value_from_rows(select_rows(arel, name, binds)) end # Returns an array of the values of the first column in a select: @@ -68,6 +81,18 @@ module ActiveRecord select_all(arel, name, binds).rows end + def query_value(sql, name = nil) # :nodoc: + single_value_from_rows(query(sql, name)) + end + + def query_values(sql, name = nil) # :nodoc: + query(sql, name).map(&:first) + end + + def query(sql, name = nil) # :nodoc: + exec_query(sql, name).rows + end + # Executes the SQL statement in the context of this connection and returns # the raw result from the connection adapter. # Note: depending on your database connector, the result returned by this @@ -120,26 +145,30 @@ module ActiveRecord # If the next id was calculated in advance (as in Oracle), it should be # passed in as +id_value+. def insert(arel, name = nil, pk = nil, id_value = nil, sequence_name = nil, binds = []) - value = exec_insert(to_sql(arel, binds), name, binds, pk, sequence_name) + sql, binds = to_sql_and_binds(arel, binds) + value = exec_insert(sql, name, binds, pk, sequence_name) id_value || last_inserted_id(value) end alias create insert # Executes the update statement and returns the number of rows affected. def update(arel, name = nil, binds = []) - exec_update(to_sql(arel, binds), name, binds) + sql, binds = to_sql_and_binds(arel, binds) + exec_update(sql, name, binds) end # Executes the delete statement and returns the number of rows affected. def delete(arel, name = nil, binds = []) - exec_delete(to_sql(arel, binds), name, binds) + sql, binds = to_sql_and_binds(arel, binds) + exec_delete(sql, name, binds) end # Returns +true+ when the connection adapter supports prepared statement # caching, otherwise returns +false+ - def supports_statement_cache? - false + def supports_statement_cache? # :nodoc: + true end + deprecate :supports_statement_cache? # Runs the given block in a database transaction, and returns the result # of the block. @@ -152,7 +181,7 @@ module ActiveRecord # # In order to get around this problem, #transaction will emulate the effect # of nested transactions, by using savepoints: - # http://dev.mysql.com/doc/refman/5.7/en/savepoint.html + # https://dev.mysql.com/doc/refman/5.7/en/savepoint.html # Savepoints are supported by MySQL and PostgreSQL. SQLite3 version >= '3.6.8' # supports savepoints. # @@ -204,7 +233,7 @@ module ActiveRecord # You should consult the documentation for your database to understand the # semantics of these different levels: # - # * http://www.postgresql.org/docs/current/static/transaction-iso.html + # * https://www.postgresql.org/docs/current/static/transaction-iso.html # * https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html # # An ActiveRecord::TransactionIsolationError will be raised if: @@ -230,7 +259,9 @@ module ActiveRecord attr_reader :transaction_manager #:nodoc: - delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction, :commit_transaction, :rollback_transaction, to: :transaction_manager + delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction, + :commit_transaction, :rollback_transaction, :materialize_transactions, + :disable_lazy_transactions!, :enable_lazy_transactions!, to: :transaction_manager def transaction_open? current_transaction.open? @@ -295,6 +326,9 @@ module ActiveRecord # Inserts the given fixture into the table. Overridden in adapters that require # something beyond a simple insert (eg. Oracle). + # Most of adapters should implement `insert_fixtures` that leverages bulk SQL insert. + # We keep this method to provide fallback + # for databases like sqlite that do not support bulk inserts. def insert_fixture(fixture, table_name) fixture = fixture.stringify_keys @@ -307,19 +341,53 @@ module ActiveRecord raise Fixture::FixtureError, %(table "#{table_name}" has no column named #{name.inspect}.) end end - key_list = fixture.keys.map { |name| quote_column_name(name) } - value_list = binds.map(&:value_for_database).map do |value| - begin - quote(value) - rescue TypeError - quote(YAML.dump(value)) - end + + table = Arel::Table.new(table_name) + + values = binds.map do |bind| + value = with_yaml_fallback(bind.value_for_database) + [table[bind.name], value] end - execute "INSERT INTO #{quote_table_name(table_name)} (#{key_list.join(', ')}) VALUES (#{value_list.join(', ')})", "Fixture Insert" + manager = Arel::InsertManager.new + manager.into(table) + manager.insert(values) + execute manager.to_sql, "Fixture Insert" + end + + # Inserts a set of fixtures into the table. Overridden in adapters that require + # something beyond a simple insert (eg. Oracle). + def insert_fixtures(fixtures, table_name) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `insert_fixtures` is deprecated and will be removed in the next version of Rails. + Consider using `insert_fixtures_set` for performance improvement. + MSG + return if fixtures.empty? + + execute(build_fixture_sql(fixtures, table_name), "Fixtures Insert") + end + + def insert_fixtures_set(fixture_set, tables_to_delete = []) + fixture_inserts = fixture_set.map do |table_name, fixtures| + next if fixtures.empty? + + build_fixture_sql(fixtures, table_name) + end.compact + + table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name table}".dup } + total_sql = Array.wrap(combine_multi_statements(table_deletes + fixture_inserts)) + + disable_referential_integrity do + transaction(requires_new: true) do + total_sql.each do |sql| + execute sql, "Fixtures Load" + yield if block_given? + end + end + end end - def empty_insert_statement_value + def empty_insert_statement_value(primary_key = nil) "DEFAULT VALUES" end @@ -348,6 +416,44 @@ module ActiveRecord alias join_to_delete join_to_update private + def default_insert_value(column) + Arel.sql("DEFAULT") + end + + def build_fixture_sql(fixtures, table_name) + columns = schema_cache.columns_hash(table_name) + + values = fixtures.map do |fixture| + fixture = fixture.stringify_keys + + unknown_columns = fixture.keys - columns.keys + if unknown_columns.any? + raise Fixture::FixtureError, %(table "#{table_name}" has no columns named #{unknown_columns.map(&:inspect).join(', ')}.) + end + + 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) + else + default_insert_value(column) + end + end + end + + table = Arel::Table.new(table_name) + manager = Arel::InsertManager.new + manager.into(table) + columns.each_key { |column| manager.columns << table[column] } + manager.values = manager.create_values_list(values) + + manager.to_sql + end + + def combine_multi_statements(total_sql) + total_sql.join(";\n") + end # Returns a subquery for the given key using the join information. def subquery_for(key, select) @@ -370,15 +476,53 @@ module ActiveRecord end def last_inserted_id(result) - row = result.rows.first + single_value_from_rows(result.rows) + end + + def single_value_from_rows(rows) + row = rows.first row && row.first end - def binds_from_relation(relation, binds) - if relation.is_a?(Relation) && binds.empty? - relation, binds = relation.arel, relation.bound_attributes + def arel_from_relation(relation) + if relation.is_a?(Relation) + relation.arel + else + relation + end + end + + # Fixture value is quoted by Arel, however scalar values + # are not quotable. In this case we want to convert + # the column value to YAML. + def with_yaml_fallback(value) + if value.is_a?(Hash) || value.is_a?(Array) + YAML.dump(value) + else + value + end + end + + class PartialQueryCollector + def initialize + @parts = [] + @binds = [] + end + + def <<(str) + @parts << str + self + end + + def add_bind(obj) + @binds << obj + @parts << Arel::Nodes::BindParam.new(1) + self + end + + def value + [@parts, @binds] end - [relation, binds] end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index e53ba4e666..8aeb934ec2 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require "concurrent/map" + module ActiveRecord module ConnectionAdapters # :nodoc: module QueryCache @@ -90,8 +94,8 @@ module ActiveRecord def select_all(arel, name = nil, binds = [], preparable: nil) if @query_cache_enabled && !locked?(arel) - arel, binds = binds_from_relation arel, binds - sql = to_sql(arel, binds) + arel = arel_from_relation(arel) + sql, binds = to_sql_and_binds(arel, binds) cache_sql(sql, name, binds) { super(sql, name, binds, preparable: preparable) } else super @@ -106,11 +110,7 @@ module ActiveRecord if @query_cache[sql].key?(binds) ActiveSupport::Notifications.instrument( "sql.active_record", - sql: sql, - binds: binds, - name: name, - connection_id: object_id, - cached: true, + cache_notification_info(sql, name, binds) ) @query_cache[sql][binds] else @@ -120,9 +120,23 @@ module ActiveRecord end end + # Database adapters can override this method to + # provide custom cache information. + def cache_notification_info(sql, name, binds) + { + sql: sql, + binds: binds, + type_casted_binds: -> { type_casted_binds(binds) }, + name: name, + connection_id: object_id, + cached: true + } + end + # If arel is locked this is a SELECT ... FOR UPDATE or somesuch. Such # queries should not be cached. def locked?(arel) + arel = arel.arel if arel.is_a?(Relation) arel.respond_to?(:locked) && arel.locked end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb index e5a24b2aca..98b1348135 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/big_decimal/conversions" require "active_support/multibyte/chars" @@ -5,14 +7,12 @@ module ActiveRecord module ConnectionAdapters # :nodoc: module Quoting # Quotes the column value to help prevent - # {SQL injection attacks}[http://en.wikipedia.org/wiki/SQL_injection]. + # {SQL injection attacks}[https://en.wikipedia.org/wiki/SQL_injection]. def quote(value) value = id_value_for_database(value) if value.is_a?(Base) - if value.respond_to?(:quoted_id) - ActiveSupport::Deprecation.warn \ - "Using #quoted_id is deprecated and will be removed in Rails 5.2." - return value.quoted_id + if value.respond_to?(:value_for_database) + value = value.value_for_database end _quote(value) @@ -24,10 +24,6 @@ module ActiveRecord def type_cast(value, column = nil) value = id_value_for_database(value) if value.is_a?(Base) - if value.respond_to?(:quoted_id) && value.respond_to?(:id) - return value.id - end - if column value = type_cast_from_column(column, value) end @@ -61,17 +57,6 @@ module ActiveRecord lookup_cast_type(column.sql_type) end - def fetch_type_metadata(sql_type) - cast_type = lookup_cast_type(sql_type) - SqlTypeMetadata.new( - sql_type: sql_type, - type: cast_type.type, - limit: cast_type.limit, - precision: cast_type.precision, - scale: cast_type.scale, - ) - end - # Quotes a string, escaping any ' (single quote) and \ (backslash) # characters. def quote_string(s) @@ -110,19 +95,19 @@ module ActiveRecord end def quoted_true - "'t'".freeze + "TRUE".freeze end def unquoted_true - "t".freeze + true end def quoted_false - "'f'".freeze + "FALSE".freeze end def unquoted_false - "f".freeze + false end # Quote date/time values for use in SQL input. Includes microseconds @@ -145,18 +130,26 @@ module ActiveRecord end def quoted_time(value) # :nodoc: - quoted_date(value).sub(/\A2000-01-01 /, "") + value = value.change(year: 2000, month: 1, day: 1) + quoted_date(value).sub(/\A\d\d\d\d-\d\d-\d\d /, "") end def quoted_binary(value) # :nodoc: "'#{quote_string(value.to_s)}'" end - private - - def type_casted_binds(binds) + def type_casted_binds(binds) # :nodoc: + if binds.first.is_a?(Array) + binds.map { |column, value| type_cast(value, column) } + else binds.map { |attr| type_cast(attr.value_for_database) } end + end + + private + def lookup_cast_type(sql_type) + type_map.lookup(sql_type) + end def id_value_for_database(value) if primary_key = value.class.primary_key diff --git a/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb b/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb index 3a06f75292..52a796b926 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/savepoints.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module Savepoints diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb index c48a4acff8..529c9d8ca6 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb @@ -1,4 +1,4 @@ -require "active_support/core_ext/string/strip" +# frozen_string_literal: true module ActiveRecord module ConnectionAdapters @@ -15,14 +15,13 @@ module ActiveRecord end delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql, - :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys_in_create?, :foreign_key_options, to: :@conn - private :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql, - :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys_in_create?, :foreign_key_options + :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys_in_create?, :foreign_key_options, + to: :@conn, private: true private def visit_AlterTable(o) - sql = "ALTER TABLE #{quote_table_name(o.name)} " + sql = "ALTER TABLE #{quote_table_name(o.name)} ".dup sql << o.adds.map { |col| accept col }.join(" ") sql << o.foreign_key_adds.map { |fk| visit_AddForeignKey fk }.join(" ") sql << o.foreign_key_drops.map { |fk| visit_DropForeignKey fk }.join(" ") @@ -30,17 +29,17 @@ module ActiveRecord def visit_ColumnDefinition(o) o.sql_type = type_to_sql(o.type, o.options) - column_sql = "#{quote_column_name(o.name)} #{o.sql_type}" + column_sql = "#{quote_column_name(o.name)} #{o.sql_type}".dup add_column_options!(column_sql, column_options(o)) unless o.type == :primary_key column_sql end def visit_AddColumnDefinition(o) - "ADD #{accept(o.column)}" + "ADD #{accept(o.column)}".dup end def visit_TableDefinition(o) - create_sql = "CREATE#{' TEMPORARY' if o.temporary} TABLE #{quote_table_name(o.name)} " + create_sql = "CREATE#{' TEMPORARY' if o.temporary} TABLE #{quote_table_name(o.name)} ".dup statements = o.columns.map { |c| accept c } statements << accept(o.primary_keys) if o.primary_keys @@ -55,16 +54,16 @@ module ActiveRecord create_sql << "(#{statements.join(', ')})" if statements.present? add_table_options!(create_sql, table_options(o)) - create_sql << " AS #{@conn.to_sql(o.as)}" if o.as + create_sql << " AS #{to_sql(o.as)}" if o.as create_sql end def visit_PrimaryKeyDefinition(o) - "PRIMARY KEY (#{o.name.join(', ')})" + "PRIMARY KEY (#{o.name.map { |name| quote_column_name(name) }.join(', ')})" end def visit_ForeignKeyDefinition(o) - sql = <<-SQL.strip_heredoc + sql = +<<~SQL CONSTRAINT #{quote_column_name(o.name)} FOREIGN KEY (#{quote_column_name(o.column)}) REFERENCES #{quote_table_name(o.to_table)} (#{quote_column_name(o.primary_key)}) @@ -93,6 +92,7 @@ module ActiveRecord if options_sql = options[:options] create_sql << " #{options_sql}" end + create_sql end def column_options(o) @@ -114,6 +114,11 @@ module ActiveRecord sql end + def to_sql(sql) + sql = sql.to_sql if sql.respond_to?(:to_sql) + sql + end + def foreign_key_in_create(from_table, to_table, options) options = foreign_key_options(from_table, to_table, options) accept ForeignKeyDefinition.new(from_table, to_table, options) @@ -125,7 +130,7 @@ module ActiveRecord when :cascade then "ON #{action} CASCADE" when :restrict then "ON #{action} RESTRICT" else - raise ArgumentError, <<-MSG.strip_heredoc + raise ArgumentError, <<~MSG '#{dependency}' is not supported for :on_update or :on_delete. Supported values are: :nullify, :cascade, :restrict MSG @@ -133,5 +138,6 @@ module ActiveRecord end end end + SchemaCreation = AbstractAdapter::SchemaCreation # :nodoc: end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 5eb7787226..582ac516c7 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -1,9 +1,47 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters #:nodoc: # Abstract representation of an index definition on a table. Instances of # this type are typically created and returned by methods in database - # adapters. e.g. ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter#indexes - IndexDefinition = Struct.new(:table, :name, :unique, :columns, :lengths, :orders, :where, :type, :using, :comment) #:nodoc: + # adapters. e.g. ActiveRecord::ConnectionAdapters::MySQL::SchemaStatements#indexes + class IndexDefinition # :nodoc: + attr_reader :table, :name, :unique, :columns, :lengths, :orders, :opclasses, :where, :type, :using, :comment + + def initialize( + table, name, + unique = false, + columns = [], + lengths: {}, + orders: {}, + opclasses: {}, + where: nil, + type: nil, + using: nil, + comment: nil + ) + @table = table + @name = name + @unique = unique + @columns = columns + @lengths = concise_options(lengths) + @orders = concise_options(orders) + @opclasses = concise_options(opclasses) + @where = where + @type = type + @using = using + @comment = comment + end + + private + def concise_options(options) + if columns.size == options.size && options.values.uniq.size == 1 + options.values.first + else + options + end + end + end # Abstract representation of a column definition. Instances of this type # are typically created by methods in TableDefinition, and added to the @@ -58,6 +96,15 @@ module ActiveRecord options[:primary_key] != default_primary_key end + def validate? + options.fetch(:validate, true) + end + alias validated? validate? + + def export_name_on_schema_dump? + name !~ ActiveRecord::SchemaDumper.fk_ignore_pattern + end + def defined_for?(to_table_ord = nil, to_table: nil, **options) if to_table_ord self.to_table == to_table_ord.to_s @@ -108,24 +155,15 @@ module ActiveRecord end end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :name, :polymorphic, :index, :foreign_key, :type, :options - private + attr_reader :name, :polymorphic, :index, :foreign_key, :type, :options - def as_options(value, default = {}) - if value.is_a?(Hash) - value - else - default - end + def as_options(value) + value.is_a?(Hash) ? value : {} end def polymorphic_options - as_options(polymorphic, options) + as_options(polymorphic).merge(options.slice(:null, :first, :after)) end def index_options @@ -181,6 +219,7 @@ module ActiveRecord :decimal, :float, :integer, + :json, :string, :text, :time, @@ -317,8 +356,12 @@ module ActiveRecord type = type.to_sym if type options = options.dup - if @columns_hash[name] && @columns_hash[name].primary_key? - raise ArgumentError, "you can't redefine the primary key column '#{name}'. To define a custom primary key, pass { id: false } to create_table." + if @columns_hash[name] + if @columns_hash[name].primary_key? + raise ArgumentError, "you can't redefine the primary key column '#{name}'. To define a custom primary key, pass { id: false } to create_table." + else + raise ArgumentError, "you can't define an already defined column '#{name}'." + end end index_options = options.delete(:index) @@ -373,6 +416,9 @@ module ActiveRecord alias :belongs_to :references def new_column_definition(name, type, **options) # :nodoc: + if integer_like_primary_key?(type, options) + type = integer_like_primary_key_type(type, options) + end type = aliased_types(type.to_s, type) options[:primary_key] ||= type == :primary_key options[:null] = false if options[:primary_key] @@ -387,6 +433,14 @@ module ActiveRecord def aliased_types(name, fallback) "timestamp" == name ? :datetime : fallback end + + def integer_like_primary_key?(type, options) + options[:primary_key] && [:integer, :bigint].include?(type) && !options.key?(:default) + end + + def integer_like_primary_key_type(type, options) + type + end end class AlterTable # :nodoc: @@ -447,6 +501,9 @@ module ActiveRecord # t.date # t.binary # t.boolean + # t.foreign_key + # t.json + # t.virtual # t.remove # t.remove_references # t.remove_belongs_to @@ -611,19 +668,19 @@ module ActiveRecord # Adds a foreign key. # - # t.foreign_key(:authors) + # t.foreign_key(:authors) # # See {connection.add_foreign_key}[rdoc-ref:SchemaStatements#add_foreign_key] - def foreign_key(*args) # :nodoc: + def foreign_key(*args) @base.add_foreign_key(name, *args) end # Checks to see if a foreign key exists. # - # t.foreign_key(:authors) unless t.foreign_key_exists?(:authors) + # t.foreign_key(:authors) unless t.foreign_key_exists?(:authors) # # See {connection.foreign_key_exists?}[rdoc-ref:SchemaStatements#foreign_key_exists?] - def foreign_key_exists?(*args) # :nodoc: + def foreign_key_exists?(*args) @base.foreign_key_exists?(name, *args) end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb index 34036d8a1d..622e00fffb 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb @@ -1,63 +1,38 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters # :nodoc: - # The goal of this module is to move Adapter specific column - # definitions to the Adapter instead of having it in the schema - # dumper itself. This code represents the normal case. - # We can then redefine how certain data types may be handled in the schema dumper on the - # Adapter level by over-writing this code inside the database specific adapters - module ColumnDumper - def column_spec(column) - [schema_type_with_virtual(column), prepare_column_options(column)] - end - - def column_spec_for_primary_key(column) - return {} if default_primary_key?(column) - spec = { id: schema_type(column).inspect } - spec.merge!(prepare_column_options(column).except!(:null)) - spec[:default] ||= "nil" if explicit_primary_key_default?(column) - spec + class SchemaDumper < SchemaDumper # :nodoc: + def self.create(connection, options) + new(connection, options) end - # This can be overridden on an Adapter level basis to support other - # extended datatypes (Example: Adding an array option in the - # PostgreSQL::ColumnDumper) - def prepare_column_options(column) - spec = {} - - if limit = schema_limit(column) - spec[:limit] = limit - end - - if precision = schema_precision(column) - spec[:precision] = precision + private + def column_spec(column) + [schema_type_with_virtual(column), prepare_column_options(column)] end - if scale = schema_scale(column) - spec[:scale] = scale + def column_spec_for_primary_key(column) + return {} if default_primary_key?(column) + spec = { id: schema_type(column).inspect } + spec.merge!(prepare_column_options(column).except!(:null)) + spec[:default] ||= "nil" if explicit_primary_key_default?(column) + spec end - default = schema_default(column) if column.has_default? - spec[:default] = default unless default.nil? - - spec[:null] = "false" unless column.null - - if collation = schema_collation(column) - spec[:collation] = collation + def prepare_column_options(column) + spec = {} + spec[:limit] = schema_limit(column) + spec[:precision] = schema_precision(column) + spec[:scale] = schema_scale(column) + spec[:default] = schema_default(column) + spec[:null] = "false" unless column.null + spec[:collation] = schema_collation(column) + spec[:comment] = column.comment.inspect if column.comment.present? + spec.compact! + spec end - spec[:comment] = column.comment.inspect if column.comment.present? - - spec - end - - # Lists the valid migration options - def migration_keys # :nodoc: - column_options_keys - end - deprecate :migration_keys - - private - def default_primary_key?(column) schema_type(column) == :bigint end @@ -67,7 +42,7 @@ module ActiveRecord end def schema_type_with_virtual(column) - if supports_virtual_columns? && column.virtual? + if @connection.supports_virtual_columns? && column.virtual? :virtual else schema_type(column) @@ -84,7 +59,7 @@ module ActiveRecord def schema_limit(column) limit = column.limit unless column.bigint? - limit.inspect if limit && limit != native_database_types[column.type][:limit] + limit.inspect if limit && limit != @connection.native_database_types[column.type][:limit] end def schema_precision(column) @@ -96,7 +71,8 @@ module ActiveRecord end def schema_default(column) - type = lookup_cast_type_from_column(column) + return unless column.has_default? + type = @connection.lookup_cast_type_from_column(column) default = type.deserialize(column.default) if default.nil? schema_expression(column) 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 c44215cd43..4a6e915b66 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require "active_record/migration/join_table" require "active_support/core_ext/string/access" -require "digest" +require "digest/sha2" module ActiveRecord module ConnectionAdapters # :nodoc: @@ -31,6 +33,8 @@ module ActiveRecord # Returns the relation names useable to back Active Record models. # For most adapters this means all #tables and #views. def data_sources + query_values(data_source_sql, "SCHEMA") + rescue NotImplementedError tables | views end @@ -39,12 +43,14 @@ module ActiveRecord # data_source_exists?(:ebooks) # def data_source_exists?(name) + query_values(data_source_sql(name), "SCHEMA").any? if name.present? + rescue NotImplementedError data_sources.include?(name.to_s) end # Returns an array of table names defined in the database. def tables - raise NotImplementedError, "#tables is not implemented" + query_values(data_source_sql(type: "BASE TABLE"), "SCHEMA") end # Checks to see if the table +table_name+ exists on the database. @@ -52,12 +58,14 @@ module ActiveRecord # table_exists?(:developers) # def table_exists?(table_name) + query_values(data_source_sql(table_name, type: "BASE TABLE"), "SCHEMA").any? if table_name.present? + rescue NotImplementedError tables.include?(table_name.to_s) end # Returns an array of view names defined in the database. def views - raise NotImplementedError, "#views is not implemented" + query_values(data_source_sql(type: "VIEW"), "SCHEMA") end # Checks to see if the view +view_name+ exists on the database. @@ -65,11 +73,13 @@ module ActiveRecord # view_exists?(:ebooks) # def view_exists?(view_name) + query_values(data_source_sql(view_name, type: "VIEW"), "SCHEMA").any? if view_name.present? + rescue NotImplementedError views.include?(view_name.to_s) end # Returns an array of indexes for the given table. - def indexes(table_name, name = nil) + def indexes(table_name) raise NotImplementedError, "#indexes is not implemented" end @@ -97,10 +107,12 @@ module ActiveRecord indexes(table_name).any? { |i| checks.all? { |check| check[i] } } end - # Returns an array of Column objects for the table specified by +table_name+. - # See the concrete implementation for details on the expected parameter values. + # Returns an array of +Column+ objects for the table specified by +table_name+. def columns(table_name) - raise NotImplementedError, "#columns is not implemented" + table_name = table_name.to_s + column_definitions(table_name).map do |field| + new_column_from_field(table_name, field) + end end # Checks to see if a column exists in a given table. @@ -178,6 +190,8 @@ module ActiveRecord # The name of the primary key, if one is to be added automatically. # Defaults to +id+. If <tt>:id</tt> is false, then this option is ignored. # + # If an array is passed, a composite primary key will be created. + # # Note that Active Record models will automatically detect their # primary key. This can be avoided by using # {self.primary_key=}[rdoc-ref:AttributeMethods::PrimaryKey::ClassMethods#primary_key=] on the model @@ -202,7 +216,7 @@ module ActiveRecord # generates: # # CREATE TABLE suppliers ( - # id int auto_increment PRIMARY KEY + # id bigint auto_increment PRIMARY KEY # ) ENGINE=InnoDB DEFAULT CHARSET=utf8 # # ====== Rename the primary key column @@ -214,7 +228,7 @@ module ActiveRecord # generates: # # CREATE TABLE objects ( - # guid int auto_increment PRIMARY KEY, + # guid bigint auto_increment PRIMARY KEY, # name varchar(80) # ) # @@ -231,18 +245,35 @@ module ActiveRecord # label varchar # ) # + # ====== Create a composite primary key + # + # create_table(:orders, primary_key: [:product_id, :client_id]) do |t| + # t.belongs_to :product + # t.belongs_to :client + # end + # + # generates: + # + # CREATE TABLE order ( + # product_id bigint NOT NULL, + # client_id bigint NOT NULL + # ); + # + # ALTER TABLE ONLY "orders" + # ADD CONSTRAINT orders_pkey PRIMARY KEY (product_id, client_id); + # # ====== Do not add a primary key column # # create_table(:categories_suppliers, id: false) do |t| - # t.column :category_id, :integer - # t.column :supplier_id, :integer + # t.column :category_id, :bigint + # t.column :supplier_id, :bigint # end # # generates: # # CREATE TABLE categories_suppliers ( - # category_id int, - # supplier_id int + # category_id bigint, + # supplier_id bigint # ) # # ====== Create a temporary table based on a query @@ -274,7 +305,7 @@ module ActiveRecord yield td if block_given? if options[:force] - drop_table(table_name, **options, if_exists: true) + drop_table(table_name, options.merge(if_exists: true)) end result = execute schema_creation.accept td @@ -330,22 +361,20 @@ module ActiveRecord # generates: # # CREATE TABLE assemblies_parts ( - # assembly_id int NOT NULL, - # part_id int NOT NULL, + # assembly_id bigint NOT NULL, + # part_id bigint NOT NULL, # ) ENGINE=InnoDB DEFAULT CHARSET=utf8 # - def create_join_table(table_1, table_2, options = {}) + def create_join_table(table_1, table_2, column_options: {}, **options) join_table_name = find_join_table_name(table_1, table_2, options) - column_options = options.delete(:column_options) || {} - column_options.reverse_merge!(null: false) - type = column_options.delete(:type) || :integer + column_options.reverse_merge!(null: false, index: false) - t1_column, t2_column = [table_1, table_2].map { |t| t.to_s.singularize.foreign_key } + t1_ref, t2_ref = [table_1, table_2].map { |t| t.to_s.singularize } create_table(join_table_name, options.merge!(id: false)) do |td| - td.send type, t1_column, column_options - td.send type, t2_column, column_options + td.references t1_ref, column_options + td.references t2_ref, column_options yield td if block_given? end end @@ -377,6 +406,8 @@ module ActiveRecord # # Defaults to false. # + # Only supported on the MySQL and PostgreSQL adapter, ignored elsewhere. + # # ====== Add a column # # change_table(:suppliers) do |t| @@ -401,7 +432,7 @@ module ActiveRecord # t.references :company # end # - # Creates a <tt>company_id(integer)</tt> column. + # Creates a <tt>company_id(bigint)</tt> column. # # ====== Add a polymorphic foreign key column # @@ -409,7 +440,7 @@ module ActiveRecord # t.belongs_to :company, polymorphic: true # end # - # Creates <tt>company_type(varchar)</tt> and <tt>company_id(integer)</tt> columns. + # Creates <tt>company_type(varchar)</tt> and <tt>company_id(bigint)</tt> columns. # # ====== Remove a column # @@ -482,15 +513,20 @@ module ActiveRecord # * <tt>:limit</tt> - # Requests a maximum column length. This is the number of characters for a <tt>:string</tt> column # and number of bytes for <tt>:text</tt>, <tt>:binary</tt> and <tt>:integer</tt> columns. + # This option is ignored by some backends. # * <tt>:default</tt> - # The column's default value. Use +nil+ for +NULL+. # * <tt>:null</tt> - - # Allows or disallows +NULL+ values in the column. This option could - # have been named <tt>:null_allowed</tt>. + # Allows or disallows +NULL+ values in the column. # * <tt>:precision</tt> - # Specifies the precision for the <tt>:decimal</tt> and <tt>:numeric</tt> columns. # * <tt>:scale</tt> - # Specifies the scale for the <tt>:decimal</tt> and <tt>:numeric</tt> columns. + # * <tt>:collation</tt> - + # Specifies the collation for a <tt>:string</tt> or <tt>:text</tt> column. If not specified, the + # column will have the same collation as the table. + # * <tt>:comment</tt> - + # Specifies the comment for the column. This option is ignored by some backends. # # Note: The precision is the total number of significant digits, # and the scale is the number of digits that can be stored following @@ -566,8 +602,9 @@ module ActiveRecord # The +type+ and +options+ parameters will be ignored if present. It can be helpful # to provide these in a migration's +change+ method so it can be reverted. # In that case, +type+ and +options+ will be used by #add_column. + # Indexes on the column are automatically removed. def remove_column(table_name, column_name, type = nil, options = {}) - execute "ALTER TABLE #{quote_table_name(table_name)} DROP #{quote_column_name(column_name)}" + execute "ALTER TABLE #{quote_table_name(table_name)} #{remove_column_for_alter(table_name, column_name, type, options)}" end # Changes the column's definition according to the new options. @@ -682,7 +719,7 @@ module ActiveRecord # # CREATE INDEX by_branch_desc_party ON accounts(branch_id DESC, party_id ASC, surname) # - # Note: MySQL doesn't yet support index order (it accepts the syntax but ignores it). + # Note: MySQL only supports index order from 8.0.1 onwards (earlier versions accepted the syntax but ignored it). # # ====== Creating a partial index # @@ -705,6 +742,19 @@ module ActiveRecord # # Note: only supported by PostgreSQL and MySQL # + # ====== Creating an index with a specific operator class + # + # add_index(:developers, :name, using: 'gist', opclass: :gist_trgm_ops) + # # CREATE INDEX developers_on_name ON developers USING gist (name gist_trgm_ops) -- PostgreSQL + # + # add_index(:developers, [:name, :city], using: 'gist', opclass: { city: :gist_trgm_ops }) + # # CREATE INDEX developers_on_name_and_city ON developers USING gist (name, city gist_trgm_ops) -- PostgreSQL + # + # add_index(:developers, [:name, :city], using: 'gist', opclass: :gist_trgm_ops) + # # CREATE INDEX developers_on_name_and_city ON developers USING gist (name gist_trgm_ops, city gist_trgm_ops) -- PostgreSQL + # + # Note: only supported by PostgreSQL + # # ====== Creating an index with a specific type # # add_index(:developers, :name, type: :fulltext) @@ -751,7 +801,7 @@ module ActiveRecord def rename_index(table_name, old_name, new_name) validate_index_length!(table_name, new_name) - # this is a naive implementation; some DBs may support this more efficiently (Postgres, for instance) + # this is a naive implementation; some DBs may support this more efficiently (PostgreSQL, for instance) old_index_def = indexes(table_name).detect { |i| i.name == old_name } return unless old_index_def add_index(table_name, old_index_def.columns, name: new_name, unique: old_index_def.unique) @@ -773,24 +823,19 @@ module ActiveRecord end # Verifies the existence of an index with a given name. - def index_name_exists?(table_name, index_name, default = nil) - unless default.nil? - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Passing default to #index_name_exists? is deprecated without replacement. - MSG - end + def index_name_exists?(table_name, index_name) index_name = index_name.to_s indexes(table_name).detect { |i| i.name == index_name } end - # Adds a reference. The reference column is an integer by default, + # Adds a reference. The reference column is a bigint by default, # the <tt>:type</tt> option can be used to specify a different type. # Optionally adds a +_type+ column, if <tt>:polymorphic</tt> option is provided. # #add_reference and #add_belongs_to are acceptable. # # The +options+ hash can include the following keys: # [<tt>:type</tt>] - # The reference column type. Defaults to +:integer+. + # The reference column type. Defaults to +:bigint+. # [<tt>:index</tt>] # Add an appropriate index. Defaults to true. # See #add_index for usage of this option. @@ -801,7 +846,7 @@ module ActiveRecord # [<tt>:null</tt>] # Whether the column allows nulls. Defaults to true. # - # ====== Create a user_id integer column + # ====== Create a user_id bigint column # # add_reference(:products, :user) # @@ -858,7 +903,7 @@ module ActiveRecord foreign_key_options = { to_table: reference_name } end foreign_key_options[:column] ||= "#{ref_name}_id" - remove_foreign_key(table_name, **foreign_key_options) + remove_foreign_key(table_name, foreign_key_options) end remove_column(table_name, "#{ref_name}_id") @@ -914,6 +959,8 @@ module ActiveRecord # Action that happens <tt>ON DELETE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+ # [<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+. def add_foreign_key(from_table, to_table, options = {}) return unless supports_foreign_keys? @@ -937,11 +984,18 @@ module ActiveRecord # # remove_foreign_key :accounts, column: :owner_id # + # Removes the foreign key on +accounts.owner_id+. + # + # remove_foreign_key :accounts, to_table: :owners + # # Removes the foreign key named +special_fk_name+ on the +accounts+ table. # # remove_foreign_key :accounts, name: :special_fk_name # - # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. + # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key + # with an addition of + # [<tt>:to_table</tt>] + # The name of the table that contains the referenced primary key. def remove_foreign_key(from_table, options_or_to_table = {}) return unless supports_foreign_keys? @@ -968,16 +1022,6 @@ module ActiveRecord foreign_key_for(from_table, options_or_to_table).present? end - def foreign_key_for(from_table, options_or_to_table = {}) # :nodoc: - return unless supports_foreign_keys? - foreign_keys(from_table).detect { |fk| fk.defined_for? options_or_to_table } - end - - def foreign_key_for!(from_table, options_or_to_table = {}) # :nodoc: - foreign_key_for(from_table, options_or_to_table) || \ - raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options_or_to_table}") - end - def foreign_key_column_for(table_name) # :nodoc: prefix = Base.table_name_prefix suffix = Base.table_name_suffix @@ -993,32 +1037,9 @@ module ActiveRecord end def dump_schema_information #:nodoc: - versions = ActiveRecord::SchemaMigration.order("version").pluck(:version) - insert_versions_sql(versions) - end - - def insert_versions_sql(versions) # :nodoc: - sm_table = quote_table_name(ActiveRecord::Migrator.schema_migrations_table_name) - - if versions.is_a?(Array) - sql = "INSERT INTO #{sm_table} (version) VALUES\n" - sql << versions.map { |v| "(#{quote(v)})" }.join(",\n") - sql << ";\n\n" - sql - else - "INSERT INTO #{sm_table} (version) VALUES (#{quote(versions)});" - end - end - - def initialize_schema_migrations_table # :nodoc: - ActiveRecord::SchemaMigration.create_table + versions = ActiveRecord::SchemaMigration.all_versions + insert_versions_sql(versions) if versions.any? end - deprecate :initialize_schema_migrations_table - - def initialize_internal_metadata_table # :nodoc: - ActiveRecord::InternalMetadata.create_table - end - deprecate :initialize_internal_metadata_table def internal_string_options_for_primary_key # :nodoc: { primary_key: true } @@ -1027,12 +1048,11 @@ module ActiveRecord def assume_migrated_upto_version(version, migrations_paths) migrations_paths = Array(migrations_paths) version = version.to_i - sm_table = quote_table_name(ActiveRecord::Migrator.schema_migrations_table_name) + sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) - migrated = select_values("SELECT version FROM #{sm_table}").map(&:to_i) - paths = migrations_paths.map { |p| "#{p}/[0-9]*_*.rb" } - versions = Dir[*paths].map do |filename| - filename.split("/").last.split("_").first.to_i + migrated = ActiveRecord::SchemaMigration.all_versions.map(&:to_i) + versions = migration_context.migration_files.map do |file| + migration_context.parse_migration_filename(file).first.to_i end unless migrated.include?(version) @@ -1044,13 +1064,7 @@ module ActiveRecord if (duplicate = inserting.detect { |v| inserting.count(v) > 1 }) raise "Duplicate migration #{duplicate}. Please renumber your migrations to resolve the conflict." end - if supports_multi_insert? - execute insert_versions_sql(inserting) - else - inserting.each do |v| - execute insert_versions_sql(v) - end - end + execute insert_versions_sql(inserting) end end @@ -1126,7 +1140,7 @@ module ActiveRecord def add_index_options(table_name, column_name, comment: nil, **options) # :nodoc: column_names = index_column_names(column_name) - options.assert_valid_keys(:unique, :order, :name, :where, :length, :internal, :using, :algorithm, :type) + options.assert_valid_keys(:unique, :order, :name, :where, :length, :internal, :using, :algorithm, :type, :opclass) index_type = options[:type].to_s if options.key?(:type) index_type ||= options[:unique] ? "UNIQUE" : "" @@ -1165,30 +1179,36 @@ module ActiveRecord end # Changes the comment for a column or removes it if +nil+. - def change_column_comment(table_name, column_name, comment) #:nodoc: + def change_column_comment(table_name, column_name, comment) raise NotImplementedError, "#{self.class} does not support changing column comments" end + def create_schema_dumper(options) # :nodoc: + SchemaDumper.create(self, options) + end + private def column_options_keys [:limit, :precision, :scale, :default, :null, :collation, :comment] end def add_index_sort_order(quoted_columns, **options) - if order = options[:order] - case order - when Hash - order = order.symbolize_keys - quoted_columns.each { |name, column| column << " #{order[name].upcase}" if order[name].present? } - when String - quoted_columns.each { |name, column| column << " #{order.upcase}" if order.present? } - end + orders = options_for_index_columns(options[:order]) + quoted_columns.each do |name, column| + column << " #{orders[name].upcase}" if orders[name].present? end + end - quoted_columns + def options_for_index_columns(options) + if options.is_a?(Hash) + options.symbolize_keys + else + Hash.new { |hash, column| hash[column] = options } + end end - # Overridden by the MySQL adapter for supporting index lengths + # Overridden by the MySQL adapter for supporting index lengths and by + # the PostgreSQL adapter for supporting operator classes. def add_options_for_index_columns(quoted_columns, **options) if supports_index_sort_order? quoted_columns = add_index_sort_order(quoted_columns, options) @@ -1256,6 +1276,10 @@ module ActiveRecord end end + def schema_creation + SchemaCreation.new(self) + end + def create_table_definition(*args) TableDefinition.new(*args) end @@ -1264,6 +1288,17 @@ module ActiveRecord AlterTable.new create_table_definition(name) end + def fetch_type_metadata(sql_type) + cast_type = lookup_cast_type(sql_type) + SqlTypeMetadata.new( + sql_type: sql_type, + type: cast_type.type, + limit: cast_type.limit, + precision: cast_type.precision, + scale: cast_type.scale, + ) + end + def index_column_names(column_names) if column_names.is_a?(String) && /\W/.match?(column_names) column_names @@ -1281,13 +1316,32 @@ module ActiveRecord end def foreign_key_name(table_name, options) - identifier = "#{table_name}_#{options.fetch(:column)}_fk" - hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10) options.fetch(:name) do + identifier = "#{table_name}_#{options.fetch(:column)}_fk" + hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10) + "fk_rails_#{hashed_identifier}" end end + def foreign_key_for(from_table, options_or_to_table = {}) + return unless supports_foreign_keys? + foreign_keys(from_table).detect { |fk| fk.defined_for? options_or_to_table } + end + + def foreign_key_for!(from_table, options_or_to_table = {}) + foreign_key_for(from_table, options_or_to_table) || \ + raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options_or_to_table}") + end + + def extract_foreign_key_action(specifier) + case specifier + when "CASCADE"; :cascade + when "SET NULL"; :nullify + when "RESTRICT"; :restrict + end + end + def validate_index_length!(table_name, new_name, internal = false) max_index_length = internal ? index_name_length : allowed_index_name_length @@ -1307,6 +1361,41 @@ module ActiveRecord def can_remove_index_by_name?(options) options.is_a?(Hash) && options.key?(:name) && options.except(:name, :algorithm).empty? 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) + schema_creation.accept(AddColumnDefinition.new(cd)) + end + + def remove_column_for_alter(table_name, column_name, type = nil, options = {}) + "DROP COLUMN #{quote_column_name(column_name)}" + end + + def remove_columns_for_alter(table_name, *column_names) + column_names.map { |column_name| remove_column_for_alter(table_name, column_name) } + end + + def insert_versions_sql(versions) + sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) + + if versions.is_a?(Array) + sql = "INSERT INTO #{sm_table} (version) VALUES\n".dup + sql << versions.map { |v| "(#{quote(v)})" }.join(",\n") + sql << ";\n\n" + sql + else + "INSERT INTO #{sm_table} (version) VALUES (#{quote(versions)});" + end + end + + def data_source_sql(name = nil, type: nil) + raise NotImplementedError + end + + def quoted_scope(name = nil, type: nil) + raise NotImplementedError + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index 6bb072dd73..564b226b39 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -1,10 +1,15 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters class TransactionState - VALID_STATES = Set.new([:committed, :rolledback, nil]) - def initialize(state = nil) @state = state + @children = [] + end + + def add_child(state) + @children << state end def finalized? @@ -12,11 +17,23 @@ module ActiveRecord end def committed? - @state == :committed + @state == :committed || @state == :fully_committed + end + + def fully_committed? + @state == :fully_committed end def rolledback? - @state == :rolledback + @state == :rolledback || @state == :fully_rolledback + end + + def fully_rolledback? + @state == :fully_rolledback + end + + def fully_completed? + completed? end def completed? @@ -24,10 +41,43 @@ module ActiveRecord end def set_state(state) - unless VALID_STATES.include?(state) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + The set_state method is deprecated and will be removed in + Rails 6.0. Please use rollback! or commit! to set transaction + state directly. + MSG + case state + when :rolledback + rollback! + when :committed + commit! + when nil + nullify! + else raise ArgumentError, "Invalid transaction state: #{state}" end - @state = state + end + + def rollback! + @children.each { |c| c.rollback! } + @state = :rolledback + end + + def full_rollback! + @children.each { |c| c.rollback! } + @state = :fully_rolledback + end + + def commit! + @state = :committed + end + + def full_commit! + @state = :fully_committed + end + + def nullify! + @state = nil end end @@ -41,13 +91,14 @@ module ActiveRecord end class Transaction #:nodoc: - attr_reader :connection, :state, :records, :savepoint_name - attr_writer :joinable + attr_reader :connection, :state, :records, :savepoint_name, :isolation_level def initialize(connection, options, run_commit_callbacks: false) @connection = connection @state = TransactionState.new @records = [] + @isolation_level = options[:isolation] + @materialized = false @joinable = options.fetch(:joinable, true) @run_commit_callbacks = run_commit_callbacks end @@ -56,8 +107,12 @@ module ActiveRecord records << record end - def rollback - @state.set_state(:rolledback) + def materialize! + @materialized = true + end + + def materialized? + @materialized end def rollback_records @@ -71,10 +126,6 @@ module ActiveRecord end end - def commit - @state.set_state(:committed) - end - def before_commit_records records.uniq.each(&:before_committed!) if @run_commit_callbacks end @@ -100,45 +151,55 @@ module ActiveRecord end class SavepointTransaction < Transaction - def initialize(connection, savepoint_name, options, *args) - super(connection, options, *args) - if options[:isolation] + def initialize(connection, savepoint_name, parent_transaction, *args) + super(connection, *args) + + parent_transaction.state.add_child(@state) + + if isolation_level raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction" end - connection.create_savepoint(@savepoint_name = savepoint_name) + + @savepoint_name = savepoint_name end - def rollback - connection.rollback_to_savepoint(savepoint_name) + def materialize! + connection.create_savepoint(savepoint_name) super end + def rollback + connection.rollback_to_savepoint(savepoint_name) if materialized? + @state.rollback! + end + def commit - connection.release_savepoint(savepoint_name) - super + connection.release_savepoint(savepoint_name) if materialized? + @state.commit! end def full_rollback?; false; end end class RealTransaction < Transaction - def initialize(connection, options, *args) - super - if options[:isolation] - connection.begin_isolated_db_transaction(options[:isolation]) + def materialize! + if isolation_level + connection.begin_isolated_db_transaction(isolation_level) else connection.begin_db_transaction end + + super end def rollback - connection.rollback_db_transaction - super + connection.rollback_db_transaction if materialized? + @state.full_rollback! end def commit - connection.commit_db_transaction - super + connection.commit_db_transaction if materialized? + @state.full_commit! end end @@ -146,60 +207,103 @@ module ActiveRecord def initialize(connection) @stack = [] @connection = connection + @has_unmaterialized_transactions = false + @materializing_transactions = false + @lazy_transactions_enabled = true end def begin_transaction(options = {}) - run_commit_callbacks = !current_transaction.joinable? - transaction = - if @stack.empty? - RealTransaction.new(@connection, options, run_commit_callbacks: run_commit_callbacks) - else - SavepointTransaction.new(@connection, "active_record_#{@stack.size}", options, - run_commit_callbacks: run_commit_callbacks) - end + @connection.lock.synchronize do + run_commit_callbacks = !current_transaction.joinable? + transaction = + if @stack.empty? + RealTransaction.new(@connection, options, run_commit_callbacks: run_commit_callbacks) + else + SavepointTransaction.new(@connection, "active_record_#{@stack.size}", @stack.last, options, + run_commit_callbacks: run_commit_callbacks) + end - @stack.push(transaction) - transaction + transaction.materialize! unless @connection.supports_lazy_transactions? && lazy_transactions_enabled? + @stack.push(transaction) + @has_unmaterialized_transactions = true if @connection.supports_lazy_transactions? + transaction + end end - def commit_transaction - transaction = @stack.last + def disable_lazy_transactions! + materialize_transactions + @lazy_transactions_enabled = false + end + + def enable_lazy_transactions! + @lazy_transactions_enabled = true + end + + def lazy_transactions_enabled? + @lazy_transactions_enabled + end + + def materialize_transactions + return if @materializing_transactions + return unless @has_unmaterialized_transactions - begin - transaction.before_commit_records - ensure - @stack.pop + @connection.lock.synchronize do + begin + @materializing_transactions = true + @stack.each { |t| t.materialize! unless t.materialized? } + ensure + @materializing_transactions = false + end + @has_unmaterialized_transactions = false end + end + + def commit_transaction + @connection.lock.synchronize do + transaction = @stack.last - transaction.commit - transaction.commit_records + begin + transaction.before_commit_records + ensure + @stack.pop + end + + transaction.commit + transaction.commit_records + end end def rollback_transaction(transaction = nil) - transaction ||= @stack.pop - transaction.rollback - transaction.rollback_records + @connection.lock.synchronize do + transaction ||= @stack.pop + transaction.rollback + transaction.rollback_records + end end def within_new_transaction(options = {}) - transaction = begin_transaction options - yield - rescue Exception => error - if transaction - rollback_transaction - after_failure_actions(transaction, error) - end - raise - ensure - unless error - if Thread.current.status == "aborting" - rollback_transaction if transaction - else - begin - commit_transaction - rescue Exception - rollback_transaction(transaction) unless transaction.state.completed? - raise + @connection.lock.synchronize do + begin + transaction = begin_transaction options + yield + rescue Exception => error + if transaction + rollback_transaction + after_failure_actions(transaction, error) + end + raise + ensure + unless error + if Thread.current.status == "aborting" + rollback_transaction if transaction + else + begin + commit_transaction if transaction + rescue Exception + rollback_transaction(transaction) unless transaction.state.completed? + raise + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index ef1d9f81a9..42ed9ce82d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -1,11 +1,15 @@ -require "active_record/type" +# frozen_string_literal: true + require "active_record/connection_adapters/determine_if_preparable_visitor" require "active_record/connection_adapters/schema_cache" require "active_record/connection_adapters/sql_type_metadata" require "active_record/connection_adapters/abstract/schema_dumper" require "active_record/connection_adapters/abstract/schema_creation" +require "active_support/concurrency/load_interlock_aware_monitor" require "arel/collectors/bind" +require "arel/collectors/composite" require "arel/collectors/sql_string" +require "arel/collectors/substitute_binds" module ActiveRecord module ConnectionAdapters # :nodoc: @@ -68,17 +72,20 @@ module ActiveRecord include Quoting, DatabaseStatements, SchemaStatements include DatabaseLimits include QueryCache - include ColumnDumper include Savepoints SIMPLE_INT = /\A\d+\z/ attr_accessor :visitor, :pool - attr_reader :schema_cache, :owner, :logger + attr_reader :schema_cache, :owner, :logger, :prepared_statements, :lock alias :in_use? :owner + set_callback :checkin, :after, :enable_lazy_transactions! + def self.type_cast_config_to_integer(config) - if config =~ SIMPLE_INT + if config.is_a?(Integer) + config + elsif SIMPLE_INT.match?(config) config.to_i else config @@ -93,8 +100,6 @@ module ActiveRecord end end - attr_reader :prepared_statements - def initialize(connection, logger = nil, config = {}) # :nodoc: super() @@ -104,10 +109,11 @@ module ActiveRecord @logger = logger @config = config @pool = nil + @idle_since = Concurrent.monotonic_time @schema_cache = SchemaCache.new self @quoted_column_names, @quoted_table_names = {}, {} @visitor = arel_visitor - @lock = Monitor.new + @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) @prepared_statements = true @@ -115,6 +121,22 @@ module ActiveRecord else @prepared_statements = false end + + @advisory_locks_enabled = self.class.type_cast_config_to_boolean( + config.fetch(:advisory_locks, true) + ) + end + + def replica? + @config[:replica] || false + end + + def migrations_paths # :nodoc: + @config[:migrations_paths] || Migrator.migrations_paths + end + + def migration_context # :nodoc: + MigrationContext.new(migrations_paths) end class Version @@ -127,53 +149,20 @@ module ActiveRecord def <=>(version_string) @version <=> version_string.split(".").map(&:to_i) end - end - class BindCollector < Arel::Collectors::Bind - def compile(bvs, conn) - casted_binds = bvs.map(&:value_for_database) - super(casted_binds.map { |value| conn.quote(value) }) + def to_s + @version.join(".") end end - class SQLString < Arel::Collectors::SQLString - def compile(bvs, conn) - super(bvs) - end - end - - def collector - if prepared_statements - SQLString.new - else - BindCollector.new - end - end - - def arel_visitor # :nodoc: - Arel::Visitors::ToSql.new(self) - end - def valid_type?(type) # :nodoc: !native_database_types[type].nil? end - def schema_creation - SchemaCreation.new self - end - - # Returns an array of +Column+ objects for the table specified by +table_name+. - def columns(table_name) # :nodoc: - table_name = table_name.to_s - column_definitions(table_name).map do |field| - new_column_from_field(table_name, field) - end - end - # this method must only be called while holding connection pool's mutex def lease if in_use? - msg = "Cannot lease connection, " + msg = "Cannot lease connection, ".dup if @owner == Thread.current msg << "it is already leased by the current thread." else @@ -200,6 +189,7 @@ module ActiveRecord "Current thread: #{Thread.current}." end + @idle_since = Concurrent.monotonic_time @owner = nil else raise ActiveRecordError, "Cannot expire connection, it is not currently leased." @@ -219,6 +209,12 @@ module ActiveRecord end end + # Seconds since this connection was returned to the pool + def seconds_idle # :nodoc: + return 0 if in_use? + Concurrent.monotonic_time - @idle_since + end + def unprepared_statement old_prepared_statements, @prepared_statements = @prepared_statements, false yield @@ -232,16 +228,6 @@ module ActiveRecord self.class::ADAPTER_NAME end - def supports_migrations? # :nodoc: - true - end - deprecate :supports_migrations? - - def supports_primary_key? # :nodoc: - true - end - deprecate :supports_primary_key? - # Does this adapter support DDL rollbacks in transactions? That is, would # CREATE TABLE or ALTER TABLE get rolled back by a transaction? def supports_ddl_transactions? @@ -310,6 +296,11 @@ module ActiveRecord false end + # Does this adapter support creating invalid constraints? + def supports_validate_constraints? + false + end + # Does this adapter support creating foreign key constraints # in the same statement as creating the table? def supports_foreign_keys_in_create? @@ -345,12 +336,22 @@ module ActiveRecord def supports_multi_insert? true end + deprecate :supports_multi_insert? # Does this adapter support virtual columns? def supports_virtual_columns? false end + # Does this adapter support foreign/external tables? + def supports_foreign_tables? + false + end + + def supports_lazy_transactions? + false + end + # This is meant to be implemented by the adapters that support extensions def disable_extension(name) end @@ -359,6 +360,10 @@ module ActiveRecord def enable_extension(name) end + def advisory_locks_enabled? # :nodoc: + supports_advisory_locks? && @advisory_locks_enabled + end + # This is meant to be implemented by the adapters that support advisory # locks # @@ -413,6 +418,19 @@ module ActiveRecord reset_transaction end + # Immediately forget this connection ever existed. Unlike disconnect!, + # this will not communicate with the server. + # + # After calling this method, the behavior of all other methods becomes + # undefined. This is called internally just before a forked process gets + # rid of a connection that belonged to its parent. + def discard! + # This should be overridden by concrete adapters. + # + # Prevent @connection's finalizer from touching the socket, or + # otherwise communicating with its server, when it is collected. + end + # Reset the state of this connection, directing the DBMS to clear # transactions and other connection-related server-side state. Usually a # database-dependent operation. @@ -438,20 +456,18 @@ module ActiveRecord # Checks whether the connection to the database is still active (i.e. not stale). # This is done under the hood by calling #active?. If the connection # is no longer active, then this method will reconnect to the database. - def verify!(*ignored) - if ignored.size > 0 - ActiveSupport::Deprecation.warn("Passing arguments to #verify method of the connection has no effect and has been deprecated. Please remove all arguments from the #verify method call.") - end + def verify! reconnect! unless active? end # Provides access to the underlying database driver for this adapter. For # example, this method returns a Mysql2::Client object in case of Mysql2Adapter, - # and a PGconn object in case of PostgreSQLAdapter. + # and a PG::Connection object in case of PostgreSQLAdapter. # # This is useful for when you need to call a proprietary method such as # PostgreSQL's lo_* methods. def raw_connection + disable_lazy_transactions! @connection end @@ -477,40 +493,8 @@ module ActiveRecord pool.checkin self end - def type_map # :nodoc: - @type_map ||= Type::TypeMap.new.tap do |mapping| - initialize_type_map(mapping) - end - end - - def new_column(name, default, sql_type_metadata, null, table_name, default_function = nil, collation = nil) # :nodoc: - Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation) - end - - def lookup_cast_type(sql_type) # :nodoc: - type_map.lookup(sql_type) - end - def column_name_for_operation(operation, node) # :nodoc: - visitor.accept(node, collector).value - end - - def combine_bind_parameters( - from_clause: [], - join_clause: [], - where_clause: [], - having_clause: [], - limit: nil, - offset: nil - ) # :nodoc: - result = from_clause + join_clause + where_clause + having_clause - if limit - result << limit - end - if offset - result << offset - end - result + visitor.compile(node) end def default_index_type?(index) # :nodoc: @@ -518,8 +502,13 @@ module ActiveRecord end private + def type_map + @type_map ||= Type::TypeMap.new.tap do |mapping| + initialize_type_map(mapping) + end + end - def initialize_type_map(m) + def initialize_type_map(m = type_map) register_class_with_limit m, %r(boolean)i, Type::Boolean register_class_with_limit m, %r(char)i, Type::String register_class_with_limit m, %r(binary)i, Type::Binary @@ -537,6 +526,8 @@ module ActiveRecord m.alias_type %r(number)i, "decimal" m.alias_type %r(double)i, "float" + m.register_type %r(^json)i, Type::Json.new + m.register_type(%r(decimal)i) do |sql_type| scale = extract_scale(sql_type) precision = extract_precision(sql_type) @@ -552,7 +543,7 @@ module ActiveRecord def reload_type_map type_map.clear - initialize_type_map(type_map) + initialize_type_map end def register_class_with_limit(mapping, key, klass) @@ -581,12 +572,7 @@ module ActiveRecord end def extract_limit(sql_type) - case sql_type - when /^bigint/i - 8 - when /\((.*)\)/ - $1.to_i - end + $1.to_i if sql_type =~ /\((.*)\)/ end def translate_exception_class(e, sql) @@ -610,12 +596,14 @@ module ActiveRecord type_casted_binds: type_casted_binds, statement_name: statement_name, connection_id: object_id) do + begin @lock.synchronize do yield end + rescue => e + raise translate_exception_class(e, sql) end - rescue => e - raise translate_exception_class(e, sql) + end end def translate_exception(exception, message) @@ -637,6 +625,24 @@ module ActiveRecord columns(table_name).detect { |c| c.name == column_name } || raise(ActiveRecordError, "No such column: #{table_name}.#{column_name}") end + + def collector + if prepared_statements + Arel::Collectors::Composite.new( + Arel::Collectors::SQLString.new, + Arel::Collectors::Bind.new, + ) + else + Arel::Collectors::SubstituteBinds.new( + self, + Arel::Collectors::SQLString.new, + ) + end + end + + def arel_visitor + Arel::Visitors::ToSql.new(self) + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index e3b6327dd8..2d287d56e8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/mysql/column" @@ -6,27 +8,14 @@ require "active_record/connection_adapters/mysql/quoting" require "active_record/connection_adapters/mysql/schema_creation" require "active_record/connection_adapters/mysql/schema_definitions" require "active_record/connection_adapters/mysql/schema_dumper" +require "active_record/connection_adapters/mysql/schema_statements" require "active_record/connection_adapters/mysql/type_metadata" -require "active_support/core_ext/string/strip" - module ActiveRecord module ConnectionAdapters class AbstractMysqlAdapter < AbstractAdapter include MySQL::Quoting - include MySQL::ColumnDumper - - def update_table_definition(table_name, base) # :nodoc: - MySQL::Table.new(table_name, base) - end - - def schema_creation # :nodoc: - MySQL::SchemaCreation.new(self) - end - - def arel_visitor # :nodoc: - Arel::Visitors::MySQL.new(self) - end + include MySQL::SchemaStatements ## # :singleton-method: @@ -35,15 +24,14 @@ module ActiveRecord # to your application.rb file: # # ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans = false - class_attribute :emulate_booleans - self.emulate_booleans = true + class_attribute :emulate_booleans, default: true NATIVE_DATABASE_TYPES = { primary_key: "bigint auto_increment PRIMARY KEY", string: { name: "varchar", limit: 255 }, text: { name: "text", limit: 65535 }, integer: { name: "int", limit: 4 }, - float: { name: "float" }, + float: { name: "float", limit: 24 }, decimal: { name: "decimal" }, datetime: { name: "datetime" }, timestamp: { name: "timestamp" }, @@ -54,12 +42,9 @@ module ActiveRecord json: { name: "json" }, } - INDEX_TYPES = [:fulltext, :spatial] - INDEX_USINGS = [:btree, :hash] - - class StatementPool < ConnectionAdapters::StatementPool + class StatementPool < ConnectionAdapters::StatementPool # :nodoc: private def dealloc(stmt) - stmt[:stmt].close + stmt.close end end @@ -69,20 +54,12 @@ module ActiveRecord @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) if version < "5.1.10" - raise "Your version of MySQL (#{full_version.match(/^\d+\.\d+\.\d+/)[0]}) is too old. Active Record supports MySQL >= 5.1.10." + raise "Your version of MySQL (#{version_string}) is too old. Active Record supports MySQL >= 5.1.10." end end - CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"] - - def internal_string_options_for_primary_key # :nodoc: - super.tap { |options| - options[:collation] = collation.sub(/\A[^_]+/, "utf8") if CHARSETS_OF_4BYTES_MAXLEN.include?(charset) - } - end - def version #:nodoc: - @version ||= Version.new(full_version.match(/^\d+\.\d+\.\d+/)[0]) + @version ||= Version.new(version_string) end def mariadb? # :nodoc: @@ -93,16 +70,8 @@ module ActiveRecord true end - # Returns true, since this connection adapter supports prepared statement - # caching. - def supports_statement_cache? - true - end - - # Technically MySQL allows to create indexes with the sort order syntax - # but at the moment (5.5) it doesn't yet implement them def supports_index_sort_order? - true + !mariadb? && version >= "8.0.1" end def supports_transaction_isolation? @@ -146,11 +115,11 @@ module ActiveRecord end def get_advisory_lock(lock_name, timeout = 0) # :nodoc: - select_value("SELECT GET_LOCK(#{quote(lock_name)}, #{timeout})") == 1 + query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1 end def release_advisory_lock(lock_name) # :nodoc: - select_value("SELECT RELEASE_LOCK(#{quote(lock_name)})") == 1 + query_value("SELECT RELEASE_LOCK(#{quote(lock_name.to_s)})") == 1 end def native_database_types @@ -158,7 +127,7 @@ module ActiveRecord end def index_algorithms - { default: "ALGORITHM = DEFAULT", copy: "ALGORITHM = COPY", inplace: "ALGORITHM = INPLACE" } + { default: "ALGORITHM = DEFAULT".dup, copy: "ALGORITHM = COPY".dup, inplace: "ALGORITHM = INPLACE".dup } end # HELPER METHODS =========================================== @@ -169,10 +138,6 @@ module ActiveRecord raise NotImplementedError end - def new_column(*args) #:nodoc: - MySQL::Column.new(*args) - end - # Must return the MySQL error number from the exception, if the exception has an # error number. def error_number(exception) # :nodoc: @@ -182,7 +147,7 @@ module ActiveRecord # REFERENTIAL INTEGRITY ==================================== def disable_referential_integrity #:nodoc: - old = select_value("SELECT @@FOREIGN_KEY_CHECKS") + old = query_value("SELECT @@FOREIGN_KEY_CHECKS") begin update("SET FOREIGN_KEY_CHECKS = 0") @@ -215,6 +180,8 @@ module ActiveRecord # Executes the SQL statement in the context of this connection. def execute(sql, name = nil) + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @connection.query(sql) @@ -258,7 +225,7 @@ module ActiveRecord end end - def empty_insert_statement_value + def empty_insert_statement_value(primary_key = nil) "VALUES ()" end @@ -274,7 +241,7 @@ module ActiveRecord end # Create a new MySQL database with optional <tt>:charset</tt> and <tt>:collation</tt>. - # Charset defaults to utf8. + # Charset defaults to utf8mb4. # # Example: # create_database 'charset_test', charset: 'latin1', collation: 'latin1_bin' @@ -282,9 +249,9 @@ module ActiveRecord # create_database 'matt_development', charset: :big5 def create_database(name, options = {}) if options[:collation] - execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset] || 'utf8')} COLLATE #{quote_table_name(options[:collation])}" + execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT COLLATE #{quote_table_name(options[:collation])}" else - execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset] || 'utf8')}" + execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset] || 'utf8mb4')}" end end @@ -297,7 +264,7 @@ module ActiveRecord end def current_database - select_value "SELECT DATABASE() as db" + query_value("SELECT database()", "SCHEMA") end # Returns the database character set. @@ -310,120 +277,25 @@ module ActiveRecord show_variable "collation_database" end - def tables # :nodoc: - sql = "SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE'" - sql << " AND table_schema = #{quote(@config[:database])}" - - select_values(sql, "SCHEMA") - end - - def views # :nodoc: - select_values("SHOW FULL TABLES WHERE table_type = 'VIEW'", "SCHEMA") - end - - def data_sources # :nodoc: - sql = "SELECT table_name FROM information_schema.tables " - sql << "WHERE table_schema = #{quote(@config[:database])}" - - select_values(sql, "SCHEMA") - end - - def table_exists?(table_name) # :nodoc: - return false unless table_name.present? - - schema, name = extract_schema_qualified_name(table_name) - - sql = "SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE'" - sql << " AND table_schema = #{quote(schema)} AND table_name = #{quote(name)}" - - select_values(sql, "SCHEMA").any? - end - - def data_source_exists?(table_name) # :nodoc: - return false unless table_name.present? - - schema, name = extract_schema_qualified_name(table_name) - - sql = "SELECT table_name FROM information_schema.tables " - sql << "WHERE table_schema = #{quote(schema)} AND table_name = #{quote(name)}" - - select_values(sql, "SCHEMA").any? - end - - def view_exists?(view_name) # :nodoc: - return false unless view_name.present? - - schema, name = extract_schema_qualified_name(view_name) - - sql = "SELECT table_name FROM information_schema.tables WHERE table_type = 'VIEW'" - sql << " AND table_schema = #{quote(schema)} AND table_name = #{quote(name)}" - - select_values(sql, "SCHEMA").any? - end - def truncate(table_name, name = nil) execute "TRUNCATE TABLE #{quote_table_name(table_name)}", name end - # Returns an array of indexes for the given table. - def indexes(table_name, name = nil) #:nodoc: - if name - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Passing name to #indexes is deprecated without replacement. - MSG - end - - indexes = [] - current_index = nil - execute_and_free("SHOW KEYS FROM #{quote_table_name(table_name)}", "SCHEMA") do |result| - each_hash(result) do |row| - if current_index != row[:Key_name] - next if row[:Key_name] == "PRIMARY" # skip the primary key - current_index = row[:Key_name] - - mysql_index_type = row[:Index_type].downcase.to_sym - index_type = INDEX_TYPES.include?(mysql_index_type) ? mysql_index_type : nil - index_using = INDEX_USINGS.include?(mysql_index_type) ? mysql_index_type : nil - indexes << IndexDefinition.new(row[:Table], row[:Key_name], row[:Non_unique].to_i == 0, [], {}, nil, nil, index_type, index_using, row[:Index_comment].presence) - end - - indexes.last.columns << row[:Column_name] - indexes.last.lengths.merge!(row[:Column_name] => row[:Sub_part].to_i) if row[:Sub_part] - end - end - - indexes - end - - def new_column_from_field(table_name, field) # :nodoc: - type_metadata = fetch_type_metadata(field[:Type], field[:Extra]) - if type_metadata.type == :datetime && field[:Default] == "CURRENT_TIMESTAMP" - default, default_function = nil, field[:Default] - else - default, default_function = field[:Default], nil - end - new_column(field[:Field], default, type_metadata, field[:Null] == "YES", table_name, default_function, field[:Collation], comment: field[:Comment].presence) - end - def table_comment(table_name) # :nodoc: - schema, name = extract_schema_qualified_name(table_name) + scope = quoted_scope(table_name) - select_value(<<-SQL.strip_heredoc, "SCHEMA") + query_value(<<~SQL, "SCHEMA").presence SELECT table_comment FROM information_schema.tables - WHERE table_schema = #{quote(schema)} - AND table_name = #{quote(name)} + WHERE table_schema = #{scope[:schema]} + AND table_name = #{scope[:name]} SQL end - def create_table(table_name, **options) #:nodoc: - super(table_name, options: "ENGINE=InnoDB", **options) - end - def bulk_change_table(table_name, operations) #:nodoc: sqls = operations.flat_map do |command, args| table, arguments = args.shift, args - method = :"#{command}_sql" + method = :"#{command}_for_alter" if respond_to?(method, true) send(method, table, *arguments) @@ -435,6 +307,11 @@ module ActiveRecord execute("ALTER TABLE #{quote_table_name(table_name)} #{sqls}") end + def change_table_comment(table_name, comment) #:nodoc: + comment = "" if comment.nil? + execute("ALTER TABLE #{quote_table_name(table_name)} COMMENT #{quote(comment)}") + end + # Renames a table. # # Example: @@ -475,32 +352,33 @@ module ActiveRecord def change_column_default(table_name, column_name, default_or_changes) #:nodoc: default = extract_new_default_value(default_or_changes) - column = column_for(table_name, column_name) - change_column table_name, column_name, column.sql_type, default: default + change_column table_name, column_name, nil, default: default end def change_column_null(table_name, column_name, null, default = nil) #:nodoc: - column = column_for(table_name, column_name) - unless null || default.nil? execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL") end - change_column table_name, column_name, column.sql_type, null: null + change_column table_name, column_name, nil, null: null + end + + def change_column_comment(table_name, column_name, comment) #:nodoc: + change_column table_name, column_name, nil, comment: comment end def change_column(table_name, column_name, type, options = {}) #:nodoc: - execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_sql(table_name, column_name, type, options)}") + execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_for_alter(table_name, column_name, type, options)}") end def rename_column(table_name, column_name, new_column_name) #:nodoc: - execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_sql(table_name, column_name, new_column_name)}") + execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_for_alter(table_name, column_name, new_column_name)}") rename_column_indexes(table_name, column_name, new_column_name) end def add_index(table_name, column_name, options = {}) #:nodoc: index_name, index_type, index_columns, _, index_algorithm, index_using, comment = add_index_options(table_name, column_name, options) - sql = "CREATE #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} ON #{quote_table_name(table_name)} (#{index_columns}) #{index_algorithm}" + sql = "CREATE #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} ON #{quote_table_name(table_name)} (#{index_columns}) #{index_algorithm}".dup execute add_sql_comment!(sql, comment) end @@ -512,22 +390,23 @@ module ActiveRecord def foreign_keys(table_name) raise ArgumentError unless table_name.present? - schema, name = extract_schema_qualified_name(table_name) + scope = quoted_scope(table_name) - fk_info = select_all(<<-SQL.strip_heredoc, "SCHEMA") + fk_info = exec_query(<<~SQL, "SCHEMA") SELECT fk.referenced_table_name AS 'to_table', fk.referenced_column_name AS 'primary_key', fk.column_name AS 'column', fk.constraint_name AS 'name', rc.update_rule AS 'on_update', rc.delete_rule AS 'on_delete' - FROM information_schema.key_column_usage fk - JOIN information_schema.referential_constraints rc + FROM information_schema.referential_constraints rc + JOIN information_schema.key_column_usage fk USING (constraint_schema, constraint_name) WHERE fk.referenced_column_name IS NOT NULL - AND fk.table_schema = #{quote(schema)} - AND fk.table_name = #{quote(name)} - AND rc.table_name = #{quote(name)} + AND fk.table_schema = #{scope[:schema]} + AND fk.table_name = #{scope[:name]} + AND rc.constraint_schema = #{scope[:schema]} + AND rc.table_name = #{scope[:name]} SQL fk_info.map do |row| @@ -585,13 +464,13 @@ module ActiveRecord super end - sql << " unsigned" if unsigned && type != :primary_key + sql = "#{sql} unsigned" if unsigned && type != :primary_key sql end # SHOW VARIABLES LIKE 'name' def show_variable(name) - select_value("SELECT @@#{name}", "SCHEMA") + query_value("SELECT @@#{name}", "SCHEMA") rescue ActiveRecord::StatementInvalid nil end @@ -599,14 +478,14 @@ module ActiveRecord def primary_keys(table_name) # :nodoc: raise ArgumentError unless table_name.present? - schema, name = extract_schema_qualified_name(table_name) + scope = quoted_scope(table_name) - select_values(<<-SQL.strip_heredoc, "SCHEMA") + query_values(<<~SQL, "SCHEMA") SELECT column_name FROM information_schema.key_column_usage WHERE constraint_name = 'PRIMARY' - AND table_schema = #{quote(schema)} - AND table_name = #{quote(name)} + AND table_schema = #{scope[:schema]} + AND table_name = #{scope[:name]} ORDER BY ordinal_position SQL end @@ -636,7 +515,7 @@ module ActiveRecord s.gsub(/\s+(?:ASC|DESC)\b/i, "") }.reject(&:blank?).map.with_index { |column, i| "#{column} AS alias_#{i}" } - [super, *order_columns].join(", ") + (order_columns << super).join(", ") end def strict_mode? @@ -647,9 +526,41 @@ module ActiveRecord index.using == :btree || super end + def insert_fixtures_set(fixture_set, tables_to_delete = []) + with_multi_statements do + super { discard_remaining_results } + end + end + private + def combine_multi_statements(total_sql) + total_sql.each_with_object([]) do |sql, total_sql_chunks| + previous_packet = total_sql_chunks.last + sql << ";\n" + if max_allowed_packet_reached?(sql, previous_packet) || total_sql_chunks.empty? + total_sql_chunks << sql + else + previous_packet << sql + end + end + end + + def max_allowed_packet_reached?(current_packet, previous_packet) + if current_packet.bytesize > max_allowed_packet + raise ActiveRecordError, "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." + elsif previous_packet.nil? + false + else + (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet + end + end - def initialize_type_map(m) + def max_allowed_packet + bytes_margin = 2 + @max_allowed_packet ||= (show_variable("max_allowed_packet") - bytes_margin) + end + + def initialize_type_map(m = type_map) super register_class_with_limit m, %r(char)i, MysqlString @@ -664,7 +575,6 @@ module ActiveRecord m.register_type %r(longblob)i, Type::Binary.new(limit: 2**32 - 1) m.register_type %r(^float)i, Type::Float.new(limit: 24) m.register_type %r(^double)i, Type::Float.new(limit: 53) - m.register_type %r(^json)i, MysqlJson.new register_integer_type m, %r(^bigint)i, limit: 8 register_integer_type m, %r(^int)i, limit: 4 @@ -707,45 +617,26 @@ module ActiveRecord end end - def fetch_type_metadata(sql_type, extra = "") - MySQL::TypeMetadata.new(super(sql_type), extra: extra) - end - - def add_index_length(quoted_columns, **options) - if length = options[:length] - case length - when Hash - length = length.symbolize_keys - quoted_columns.each { |name, column| column << "(#{length[name]})" if length[name].present? } - when Integer - quoted_columns.each { |name, column| column << "(#{length})" } - end - end - - quoted_columns - end - - def add_options_for_index_columns(quoted_columns, **options) - quoted_columns = add_index_length(quoted_columns, options) - super - end - # See https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html ER_DUP_ENTRY = 1062 ER_NOT_NULL_VIOLATION = 1048 ER_DO_NOT_HAVE_DEFAULT = 1364 + ER_ROW_IS_REFERENCED_2 = 1451 ER_NO_REFERENCED_ROW_2 = 1452 ER_DATA_TOO_LONG = 1406 ER_OUT_OF_RANGE = 1264 ER_LOCK_DEADLOCK = 1213 ER_CANNOT_ADD_FOREIGN = 1215 ER_CANNOT_CREATE_TABLE = 1005 + ER_LOCK_WAIT_TIMEOUT = 1205 + ER_QUERY_INTERRUPTED = 1317 + ER_QUERY_TIMEOUT = 3024 def translate_exception(exception, message) case error_number(exception) when ER_DUP_ENTRY RecordNotUnique.new(message) - when ER_NO_REFERENCED_ROW_2 + when ER_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2 InvalidForeignKey.new(message) when ER_CANNOT_ADD_FOREIGN mismatched_foreign_key(message) @@ -763,19 +654,20 @@ module ActiveRecord NotNullViolation.new(message) when ER_LOCK_DEADLOCK Deadlocked.new(message) + when ER_LOCK_WAIT_TIMEOUT + LockWaitTimeout.new(message) + when ER_QUERY_TIMEOUT + StatementTimeout.new(message) + when ER_QUERY_INTERRUPTED + QueryCanceled.new(message) else super end end - def add_column_sql(table_name, column_name, type, options = {}) - td = create_table_definition(table_name) - cd = td.new_column_definition(column_name, type, options) - schema_creation.accept(AddColumnDefinition.new(cd)) - end - - def change_column_sql(table_name, column_name, type, options = {}) + def change_column_for_alter(table_name, column_name, type, options = {}) column = column_for(table_name, column_name) + type ||= column.sql_type unless options.key?(:default) options[:default] = column.default @@ -794,7 +686,7 @@ module ActiveRecord schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) end - def rename_column_sql(table_name, column_name, new_column_name) + def rename_column_for_alter(table_name, column_name, new_column_name) column = column_for(table_name, column_name) options = { default: column.default, @@ -802,52 +694,43 @@ module ActiveRecord auto_increment: column.auto_increment? } - current_type = select_one("SHOW COLUMNS FROM #{quote_table_name(table_name)} LIKE '#{column_name}'", "SCHEMA")["Type"] + current_type = exec_query("SHOW COLUMNS FROM #{quote_table_name(table_name)} LIKE #{quote(column_name)}", "SCHEMA").first["Type"] td = create_table_definition(table_name) cd = td.new_column_definition(new_column_name, current_type, options) schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) end - def remove_column_sql(table_name, column_name, type = nil, options = {}) - "DROP #{quote_column_name(column_name)}" - end - - def remove_columns_sql(table_name, *column_names) - column_names.map { |column_name| remove_column_sql(table_name, column_name) } - end - - def add_index_sql(table_name, column_name, options = {}) + def add_index_for_alter(table_name, column_name, options = {}) index_name, index_type, index_columns, _, index_algorithm, index_using = add_index_options(table_name, column_name, options) index_algorithm[0, 0] = ", " if index_algorithm.present? "ADD #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})#{index_algorithm}" end - def remove_index_sql(table_name, options = {}) + def remove_index_for_alter(table_name, options = {}) index_name = index_name_for_remove(table_name, options) - "DROP INDEX #{index_name}" + "DROP INDEX #{quote_column_name(index_name)}" end - def add_timestamps_sql(table_name, options = {}) - [add_column_sql(table_name, :created_at, :datetime, options), add_column_sql(table_name, :updated_at, :datetime, options)] + def add_timestamps_for_alter(table_name, options = {}) + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] end - def remove_timestamps_sql(table_name, options = {}) - [remove_column_sql(table_name, :updated_at), remove_column_sql(table_name, :created_at)] + def remove_timestamps_for_alter(table_name, options = {}) + [remove_column_for_alter(table_name, :updated_at), remove_column_for_alter(table_name, :created_at)] end # MySQL is too stupid to create a temporary table for use subquery, so we have # to give it some prompting in the form of a subsubquery. Ugh! def subquery_for(key, select) - subsubselect = select.clone - subsubselect.projections = [key] + subselect = select.clone + subselect.projections = [key] # Materialize subquery by adding distinct # to work with MySQL 5.7.6 which sets optimizer_switch='derived_merge=on' - subsubselect.distinct unless select.limit || select.offset || select.orders.any? + subselect.distinct unless select.limit || select.offset || select.orders.any? - subselect = Arel::SelectManager.new(select.engine) - subselect.project Arel.sql(key.name) - subselect.from subsubselect.as("__active_record_temp") + key_name = quote_column_name(key.name) + Arel::SelectManager.new(subselect.as("__active_record_temp")).project(Arel.sql(key_name)) end def supports_rename_index? @@ -868,7 +751,7 @@ module ActiveRecord defaults = [":default", :default].to_set # Make MySQL reject illegal values rather than truncating or blanking them, see - # http://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_strict_all_tables + # https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_strict_all_tables # If the user has provided another value for sql_mode, don't replace it. if sql_mode = variables.delete("sql_mode") sql_mode = quote(sql_mode) @@ -885,10 +768,10 @@ module ActiveRecord sql_mode_assignment = "@@SESSION.sql_mode = #{sql_mode}, " if sql_mode # NAMES does not have an equals sign, see - # http://dev.mysql.com/doc/refman/5.7/en/set-statement.html#id944430 + # https://dev.mysql.com/doc/refman/5.7/en/set-names.html # (trailing comma because variable_assignments will always have content) if @config[:encoding] - encoding = "NAMES #{@config[:encoding]}" + encoding = "NAMES #{@config[:encoding]}".dup encoding << " COLLATE #{@config[:collation]}" if @config[:collation] encoding << ", " end @@ -913,19 +796,12 @@ module ActiveRecord end end - def extract_foreign_key_action(specifier) # :nodoc: - case specifier - when "CASCADE"; :cascade - when "SET NULL"; :nullify - end - end - def create_table_info(table_name) # :nodoc: - select_one("SHOW CREATE TABLE #{quote_table_name(table_name)}")["Create Table"] + exec_query("SHOW CREATE TABLE #{quote_table_name(table_name)}", "SCHEMA").first["Create Table"] end - def create_table_definition(*args) # :nodoc: - MySQL::TableDefinition.new(*args) + def arel_visitor + Arel::Visitors::MySQL.new(self) end def mismatched_foreign_key(message) @@ -940,12 +816,6 @@ module ActiveRecord ) end - def extract_schema_qualified_name(string) # :nodoc: - schema, name = string.to_s.scan(/[^`.\s]+|`[^`]*`/) - schema, name = @config[:database], schema unless name - [schema, name] - end - def integer_to_sql(limit) # :nodoc: case limit when 1; "tinyint" @@ -977,19 +847,15 @@ module ActiveRecord end end - class MysqlJson < Type::Internal::AbstractJson # :nodoc: - def changed_in_place?(raw_old_value, new_value) - # Normalization is required because MySQL JSON data format includes - # the space between the elements. - super(serialize(deserialize(raw_old_value)), new_value) - end + def version_string + full_version.match(/^(?:5\.5\.5-)?(\d+\.\d+\.\d+)/)[1] end class MysqlString < Type::String # :nodoc: def serialize(value) case value - when true then MySQL::Quoting::QUOTED_TRUE - when false then MySQL::Quoting::QUOTED_FALSE + when true then "1" + when false then "0" else super end end @@ -998,14 +864,13 @@ module ActiveRecord def cast_value(value) case value - when true then MySQL::Quoting::QUOTED_TRUE - when false then MySQL::Quoting::QUOTED_FALSE + when true then "1" + when false then "0" else super end end end - ActiveRecord::Type.register(:json, MysqlJson, adapter: :mysql2) ActiveRecord::Type.register(:string, MysqlString, adapter: :mysql2) ActiveRecord::Type.register(:unsigned_integer, Type::UnsignedInteger, adapter: :mysql2) end diff --git a/activerecord/lib/active_record/connection_adapters/column.rb b/activerecord/lib/active_record/connection_adapters/column.rb index 61cd7ae4cc..5d81de9fe1 100644 --- a/activerecord/lib/active_record/connection_adapters/column.rb +++ b/activerecord/lib/active_record/connection_adapters/column.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # :stopdoc: module ConnectionAdapters @@ -9,11 +11,11 @@ module ActiveRecord # Instantiates a new column in the table. # - # +name+ is the column's name, such as <tt>supplier_id</tt> in <tt>supplier_id int</tt>. + # +name+ is the column's name, such as <tt>supplier_id</tt> in <tt>supplier_id bigint</tt>. # +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, table_name = nil, default_function = nil, collation = nil, comment: nil, **) @name = name.freeze @table_name = table_name @sql_type_metadata = sql_type_metadata diff --git a/activerecord/lib/active_record/connection_adapters/connection_specification.rb b/activerecord/lib/active_record/connection_adapters/connection_specification.rb index 3e4ea28f63..2e7a78215a 100644 --- a/activerecord/lib/active_record/connection_adapters/connection_specification.rb +++ b/activerecord/lib/active_record/connection_adapters/connection_specification.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "uri" module ActiveRecord @@ -55,9 +57,7 @@ module ActiveRecord private - def uri - @uri - end + attr_reader :uri def uri_parser @uri_parser ||= URI::Parser.new @@ -114,8 +114,7 @@ module ActiveRecord class Resolver # :nodoc: attr_reader :configurations - # Accepts a hash two layers deep, keys on the first layer represent - # environments such as "production". Keys must be strings. + # Accepts a list of db config objects. def initialize(configurations) @configurations = configurations end @@ -136,34 +135,14 @@ module ActiveRecord # Resolver.new(configurations).resolve(:production) # # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" } # - def resolve(config) - if config - resolve_connection config - elsif env = ActiveRecord::ConnectionHandling::RAILS_ENV.call - resolve_symbol_connection env.to_sym + def resolve(config_or_env, pool_name = nil) + if config_or_env + resolve_connection config_or_env, pool_name else raise AdapterNotSpecified end end - # Expands each key in @configurations hash into fully resolved hash - def resolve_all - config = configurations.dup - - if env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - env_config = config[env] if config[env].is_a?(Hash) && !(config[env].key?("adapter") || config[env].key?("url")) - end - - config.reject! { |k, v| v.is_a?(Hash) && !(v.key?("adapter") || v.key?("url")) } - config.merge! env_config if env_config - - config.each do |key, value| - config[key] = resolve(value) if value - end - - config - end - # Returns an instance of ConnectionSpecification for a given adapter. # Accepts a hash one layer deep that contains all connection information. # @@ -177,17 +156,31 @@ module ActiveRecord # # => { "host" => "localhost", "database" => "foo", "adapter" => "sqlite3" } # def spec(config) - spec = resolve(config).symbolize_keys + pool_name = config if config.is_a?(Symbol) + + spec = resolve(config, pool_name).symbolize_keys raise(AdapterNotSpecified, "database configuration does not specify adapter") unless spec.key?(:adapter) + # Require the adapter itself and give useful feedback about + # 1. Missing adapter gems and + # 2. Adapter gems' missing dependencies. path_to_adapter = "active_record/connection_adapters/#{spec[:adapter]}_adapter" begin require path_to_adapter - rescue Gem::LoadError => e - raise Gem::LoadError, "Specified '#{spec[:adapter]}' for database adapter, but the gem is not loaded. Add `gem '#{e.name}'` to your Gemfile (and ensure its version is at the minimum required by ActiveRecord)." rescue LoadError => e - raise LoadError, "Could not load '#{path_to_adapter}'. Make sure that the adapter in config/database.yml is valid. If you use an adapter other than 'mysql2', 'postgresql' or 'sqlite3' add the necessary adapter gem to the Gemfile.", e.backtrace + # We couldn't require the adapter itself. Raise an exception that + # points out config typos and missing gems. + if e.path == path_to_adapter + # We can assume that a non-builtin adapter was specified, so it's + # either misspelled or missing from Gemfile. + raise e.class, "Could not load the '#{spec[:adapter]}' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace + + # Bubbled up from the adapter require. Prefix the exception message + # with some guidance about how to address it and reraise. + else + raise e.class, "Error loading the '#{spec[:adapter]}' Active Record adapter. Missing a gem it depends on? #{e.message}", e.backtrace + end end adapter_method = "#{spec[:adapter]}_connection" @@ -200,7 +193,6 @@ module ActiveRecord end private - # Returns fully resolved connection, accepts hash, string or symbol. # Always returns a hash. # @@ -221,29 +213,42 @@ module ActiveRecord # Resolver.new({}).resolve_connection("postgresql://localhost/foo") # # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" } # - def resolve_connection(spec) - case spec + def resolve_connection(config_or_env, pool_name = nil) + case config_or_env when Symbol - resolve_symbol_connection spec + resolve_symbol_connection config_or_env, pool_name when String - resolve_url_connection spec + resolve_url_connection config_or_env when Hash - resolve_hash_connection spec + resolve_hash_connection config_or_env + else + resolve_connection config_or_env end end - # Takes the environment such as +:production+ or +:development+. + # Takes the environment such as +:production+ or +:development+ and a + # pool name the corresponds to the name given by the connection pool + # to the connection. That pool name is merged into the hash with the + # name key. + # # This requires that the @configurations was initialized with a key that # matches. # - # Resolver.new("production" => {}).resolve_symbol_connection(:production) - # # => {} + # configurations = #<ActiveRecord::DatabaseConfigurations:0x00007fd9fdace3e0 + # @configurations=[ + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd9fdace250 + # @env_name="production", @spec_name="primary", @config={"database"=>"my_db"}> + # ]> # - def resolve_symbol_connection(spec) - if config = configurations[spec.to_s] - resolve_connection(config).merge("name" => spec.to_s) + # Resolver.new(configurations).resolve_symbol_connection(:production, "primary") + # # => { "database" => "my_db" } + def resolve_symbol_connection(env_name, pool_name) + db_config = configurations.find_db_config(env_name) + + if db_config + resolve_connection(db_config.config).merge("name" => pool_name.to_s) else - raise(AdapterNotSpecified, "'#{spec}' database is not configured. Available: #{configurations.keys.inspect}") + raise(AdapterNotSpecified, "'#{env_name}' database is not configured. Available: #{configurations.configurations.map(&:env_name).join(", ")}") end end diff --git a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb index 0fdc185c45..3dcb916d99 100644 --- a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb +++ b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module DetermineIfPreparableVisitor diff --git a/activerecord/lib/active_record/connection_adapters/mysql/column.rb b/activerecord/lib/active_record/connection_adapters/mysql/column.rb index c9ad47c035..fa1541019d 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/column.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL 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 8c67a7a80b..684c7042a7 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -1,25 +1,22 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL module DatabaseStatements # Returns an ActiveRecord::Result instance. - def select_all(arel, name = nil, binds = [], preparable: nil) # :nodoc: + def select_all(*) # :nodoc: result = if ExplainRegistry.collect? && prepared_statements unprepared_statement { super } else super end - @connection.next_result while @connection.more_results? + discard_remaining_results result end - # Returns an array of arrays containing the field values. - # Order is the same as that returned by +columns+. - def select_rows(arel, name = nil, binds = []) # :nodoc: - select_result(arel, name, binds) do |result| - @connection.next_result while @connection.more_results? - result.to_a - end + def query(sql, name = nil) # :nodoc: + execute(sql, name).to_a end # Executes the SQL statement in the context of this connection. @@ -32,6 +29,8 @@ module ActiveRecord end def exec_query(sql, name = "SQL", binds = [], prepare: false) + materialize_transactions + if without_prepared_statement?(binds) execute_and_free(sql, name) do |result| ActiveRecord::Result.new(result.fields, result.to_a) if result @@ -44,6 +43,8 @@ module ActiveRecord end def exec_delete(sql, name = nil, binds = []) + materialize_transactions + if without_prepared_statement?(binds) execute_and_free(sql, name) { @connection.affected_rows } else @@ -53,18 +54,51 @@ module ActiveRecord alias :exec_update :exec_delete private + def default_insert_value(column) + Arel.sql("DEFAULT") unless column.auto_increment? + end def last_inserted_id(result) @connection.last_id end - def select_result(arel, name, binds) - arel, binds = binds_from_relation(arel, binds) - sql = to_sql(arel, binds) - if without_prepared_statement?(binds) - execute_and_free(sql, name) { |result| yield result } + def discard_remaining_results + @connection.abandon_results! + end + + def supports_set_server_option? + @connection.respond_to?(:set_server_option) + end + + def multi_statements_enabled?(flags) + if flags.is_a?(Array) + flags.include?("MULTI_STATEMENTS") else - exec_stmt_and_free(sql, name, binds, cache_stmt: true) { |_, result| yield result } + (flags & Mysql2::Client::MULTI_STATEMENTS) != 0 + end + end + + def with_multi_statements + previous_flags = @config[:flags] + + unless multi_statements_enabled?(previous_flags) + if supports_set_server_option? + @connection.set_server_option(Mysql2::Client::OPTION_MULTI_STATEMENTS_ON) + else + @config[:flags] = Mysql2::Client::MULTI_STATEMENTS + reconnect! + end + end + + yield + ensure + unless multi_statements_enabled?(previous_flags) + if supports_set_server_option? + @connection.set_server_option(Mysql2::Client::OPTION_MULTI_STATEMENTS_OFF) + else + @config[:flags] = previous_flags + reconnect! + end end end @@ -77,10 +111,7 @@ module ActiveRecord log(sql, name, binds, type_casted_binds) do if cache_stmt - cache = @statements[sql] ||= { - stmt: @connection.prepare(sql) - } - stmt = cache[:stmt] + stmt = @statements[sql] ||= @connection.prepare(sql) else stmt = @connection.prepare(sql) end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/explain_pretty_printer.rb b/activerecord/lib/active_record/connection_adapters/mysql/explain_pretty_printer.rb index 9691060cd3..20c3c83664 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/explain_pretty_printer.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/explain_pretty_printer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL diff --git a/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb b/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb index d4f5906b33..be038403b8 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb @@ -1,9 +1,9 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL module Quoting # :nodoc: - QUOTED_TRUE, QUOTED_FALSE = "1".freeze, "0".freeze - def quote_column_name(name) @quoted_column_names[name] ||= "`#{super.gsub('`', '``')}`".freeze end @@ -12,18 +12,10 @@ module ActiveRecord @quoted_table_names[name] ||= super.gsub(".", "`.`").freeze end - def quoted_true - QUOTED_TRUE - end - def unquoted_true 1 end - def quoted_false - QUOTED_FALSE - end - def unquoted_false 0 end @@ -39,6 +31,13 @@ module ActiveRecord def quoted_binary(value) "x'#{value.hex}'" end + + def _type_cast(value) + case value + when Date, Time then value + else super + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb index 083cd6340f..c9ea653b77 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb @@ -1,9 +1,10 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL class SchemaCreation < AbstractAdapter::SchemaCreation # :nodoc: - delegate :add_sql_comment!, :mariadb?, to: :@conn - private :add_sql_comment!, :mariadb? + delegate :add_sql_comment!, :mariadb?, to: :@conn, private: true private @@ -16,7 +17,7 @@ module ActiveRecord end def visit_ChangeColumnDefinition(o) - change_column_sql = "CHANGE #{quote_column_name(o.name)} #{accept(o.column)}" + change_column_sql = "CHANGE #{quote_column_name(o.name)} #{accept(o.column)}".dup add_column_position!(change_column_sql, column_options(o.column)) end @@ -28,7 +29,7 @@ module ActiveRecord # By default, TIMESTAMP columns are NOT NULL, cannot contain NULL values, # and assigning NULL assigns the current timestamp. To permit a TIMESTAMP # column to contain NULL, explicitly declare it with the NULL attribute. - # See http://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html + # See https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html if /\Atimestamp\b/.match?(options[:column].sql_type) && !options[:primary_key] sql << " NULL" unless options[:null] == false || options_include_default?(options) end @@ -63,7 +64,7 @@ module ActiveRecord def index_in_create(table_name, column_name, options) index_name, index_type, index_columns, _, _, index_using, comment = @conn.add_index_options(table_name, column_name, options) - add_sql_comment!("#{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})", comment) + add_sql_comment!("#{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})".dup, comment) end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index 6d88c14d50..2ed4ad16ae 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -1,12 +1,9 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL module ColumnMethods - def primary_key(name, type = :primary_key, **options) - options[:auto_increment] = true if [:integer, :bigint].include?(type) && !options.key?(:default) - super - end - def blob(*args, **options) args.each { |name| column(name, :blob, options) } end @@ -35,10 +32,6 @@ module ActiveRecord args.each { |name| column(name, :longtext, options) } end - def json(*args, **options) - args.each { |name| column(name, :json, options) } - end - def unsigned_integer(*args, **options) args.each { |name| column(name, :unsigned_integer, options) } end @@ -66,7 +59,6 @@ module ActiveRecord when :primary_key type = :integer options[:limit] ||= 8 - options[:auto_increment] = true options[:primary_key] = true when /\Aunsigned_(?<type>.+)\z/ type = $~[:type].to_sym @@ -80,6 +72,11 @@ module ActiveRecord def aliased_types(name, fallback) fallback end + + def integer_like_primary_key_type(type, options) + options[:auto_increment] = true + type + end end class Table < ActiveRecord::ConnectionAdapters::Table 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 3e0afd9761..d23178e43c 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb @@ -1,25 +1,29 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL - module ColumnDumper # :nodoc: - def prepare_column_options(column) - spec = super - spec[:unsigned] = "true" if column.unsigned? - - if supports_virtual_columns? && column.virtual? - spec[:as] = extract_expression_for_virtual_column(column) - spec[:stored] = "true" if /\b(?:STORED|PERSISTENT)\b/.match?(column.extra) - spec = { type: schema_type(column).inspect }.merge!(spec) - end + class SchemaDumper < ConnectionAdapters::SchemaDumper # :nodoc: + private + def prepare_column_options(column) + spec = super + spec[:unsigned] = "true" if column.unsigned? + spec[:auto_increment] = "true" if column.auto_increment? - spec - end + if @connection.supports_virtual_columns? && column.virtual? + spec[:as] = extract_expression_for_virtual_column(column) + spec[:stored] = "true" if /\b(?:STORED|PERSISTENT)\b/.match?(column.extra) + spec = { type: schema_type(column).inspect }.merge!(spec) + end - def migration_keys - super + [:unsigned] - end + spec + end - private + def column_spec_for_primary_key(column) + spec = super + spec.delete(:auto_increment) if column.type == :integer && column.auto_increment? + spec + end def default_primary_key?(column) super && column.auto_increment? && !column.unsigned? @@ -47,23 +51,27 @@ module ActiveRecord def schema_collation(column) if column.collation && table_name = column.table_name @table_collation_cache ||= {} - @table_collation_cache[table_name] ||= select_one("SHOW TABLE STATUS LIKE '#{table_name}'")["Collation"] + @table_collation_cache[table_name] ||= + @connection.exec_query("SHOW TABLE STATUS LIKE #{@connection.quote(table_name)}", "SCHEMA").first["Collation"] column.collation.inspect if column.collation != @table_collation_cache[table_name] end end def extract_expression_for_virtual_column(column) - if mariadb? - create_table_info = create_table_info(column.table_name) - if %r/#{quote_column_name(column.name)} #{Regexp.quote(column.sql_type)} AS \((?<expression>.+?)\) #{column.extra}/m =~ create_table_info + if @connection.mariadb? && @connection.version < "10.2.5" + create_table_info = @connection.send(:create_table_info, column.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) + column_name = @connection.quote(column.name) sql = "SELECT generation_expression FROM information_schema.columns" \ - " WHERE table_schema = #{quote(@config[:database])}" \ - " AND table_name = #{quote(column.table_name)}" \ - " AND column_name = #{quote(column.name)}" - select_value(sql, "SCHEMA").inspect + " WHERE table_schema = #{scope[:schema]}" \ + " AND table_name = #{scope[:name]}" \ + " AND column_name = #{column_name}" + @connection.query_value(sql, "SCHEMA").inspect end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb new file mode 100644 index 0000000000..1cf210d85b --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module MySQL + module SchemaStatements # :nodoc: + # Returns an array of indexes for the given table. + def indexes(table_name) + indexes = [] + current_index = nil + execute_and_free("SHOW KEYS FROM #{quote_table_name(table_name)}", "SCHEMA") do |result| + each_hash(result) do |row| + if current_index != row[:Key_name] + next if row[:Key_name] == "PRIMARY" # skip the primary key + current_index = row[:Key_name] + + mysql_index_type = row[:Index_type].downcase.to_sym + case mysql_index_type + when :fulltext, :spatial + index_type = mysql_index_type + when :btree, :hash + index_using = mysql_index_type + end + + indexes << [ + row[:Table], + row[:Key_name], + row[:Non_unique].to_i == 0, + [], + lengths: {}, + orders: {}, + type: index_type, + using: index_using, + comment: row[:Index_comment].presence + ] + end + + indexes.last[-2] << row[:Column_name] + indexes.last[-1][:lengths][row[:Column_name]] = row[:Sub_part].to_i if row[:Sub_part] + indexes.last[-1][:orders].merge!(row[:Column_name] => :desc) if row[:Collation] == "D" + end + end + + indexes.map { |index| IndexDefinition.new(*index) } + end + + def remove_column(table_name, column_name, type = nil, options = {}) + if foreign_key_exists?(table_name, column: column_name) + remove_foreign_key(table_name, column: column_name) + end + super + end + + def internal_string_options_for_primary_key + super.tap do |options| + if CHARSETS_OF_4BYTES_MAXLEN.include?(charset) && (mariadb? || version < "8.0.0") + options[:collation] = collation.sub(/\A[^_]+/, "utf8") + end + end + end + + def update_table_definition(table_name, base) + MySQL::Table.new(table_name, base) + end + + def create_schema_dumper(options) + MySQL::SchemaDumper.create(self, options) + end + + private + CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"] + + def schema_creation + MySQL::SchemaCreation.new(self) + end + + def create_table_definition(*args) + MySQL::TableDefinition.new(*args) + end + + def new_column_from_field(table_name, field) + type_metadata = fetch_type_metadata(field[:Type], field[:Extra]) + if type_metadata.type == :datetime && /\ACURRENT_TIMESTAMP(?:\([0-6]?\))?\z/i.match?(field[:Default]) + default, default_function = nil, field[:Default] + else + default, default_function = field[:Default], nil + end + + MySQL::Column.new( + field[:Field], + default, + type_metadata, + field[:Null] == "YES", + table_name, + default_function, + field[:Collation], + comment: field[:Comment].presence + ) + end + + def fetch_type_metadata(sql_type, extra = "") + MySQL::TypeMetadata.new(super(sql_type), extra: extra) + end + + def extract_foreign_key_action(specifier) + super unless specifier == "RESTRICT" + end + + def add_index_length(quoted_columns, **options) + lengths = options_for_index_columns(options[:length]) + quoted_columns.each do |name, column| + column << "(#{lengths[name]})" if lengths[name].present? + end + end + + def add_options_for_index_columns(quoted_columns, **options) + quoted_columns = add_index_length(quoted_columns, options) + super + end + + def data_source_sql(name = nil, type: nil) + scope = quoted_scope(name, type: type) + + sql = "SELECT table_name FROM information_schema.tables".dup + sql << " WHERE table_schema = #{scope[:schema]}" + sql << " AND table_name = #{scope[:name]}" if scope[:name] + sql << " AND table_type = #{scope[:type]}" if scope[:type] + sql + end + + def quoted_scope(name = nil, type: nil) + schema, name = extract_schema_qualified_name(name) + scope = {} + scope[:schema] = schema ? quote(schema) : "database()" + scope[:name] = quote(name) if name + scope[:type] = quote(type) if type + scope + end + + def extract_schema_qualified_name(string) + schema, name = string.to_s.scan(/[^`.\s]+|`[^`]*`/) + schema, name = nil, schema unless name + [schema, name] + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb index 24dcf852e1..7ad0944d51 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module MySQL class TypeMetadata < DelegateClass(SqlTypeMetadata) # :nodoc: + undef to_yaml if method_defined?(:to_yaml) + attr_reader :extra def initialize(type_metadata, extra: "") diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 45e400b75b..92f15de219 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -1,17 +1,16 @@ +# frozen_string_literal: true + require "active_record/connection_adapters/abstract_mysql_adapter" require "active_record/connection_adapters/mysql/database_statements" -gem "mysql2", ">= 0.3.18", "< 0.5" +gem "mysql2", ">= 0.4.4", "< 0.6.0" require "mysql2" -raise "mysql2 0.4.3 is not supported. Please upgrade to 0.4.4+" if Mysql2::VERSION == "0.4.3" module ActiveRecord module ConnectionHandling # :nodoc: # Establishes a connection to the database that's used by all Active Record objects. def mysql2_connection(config) config = config.symbolize_keys - - config[:username] = "root" if config[:username].nil? config[:flags] ||= 0 if config[:flags].kind_of? Array @@ -59,6 +58,10 @@ module ActiveRecord true end + def supports_lazy_transactions? + true + end + # HELPER METHODS =========================================== def each_hash(result) # :nodoc: @@ -105,6 +108,11 @@ module ActiveRecord @connection.close end + def discard! # :nodoc: + @connection.automatic_close = false + @connection = nil + end + private def connect @@ -113,7 +121,7 @@ module ActiveRecord end def configure_connection - @connection.query_options.merge!(as: :array) + @connection.query_options[:as] = :array super end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb index 3ad1911a28..3ccc7271ab 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters # PostgreSQL-specific extensions to column definitions in a table. @@ -5,11 +7,37 @@ module ActiveRecord delegate :array, :oid, :fmod, to: :sql_type_metadata alias :array? :array + def initialize(*, max_identifier_length: 63, **) + super + @max_identifier_length = max_identifier_length + end + def serial? return unless default_function - %r{\Anextval\('"?#{table_name}_#{name}_seq"?'::regclass\)\z} === default_function + if %r{\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z} =~ default_function + sequence_name_from_parts(table_name, name, suffix) == sequence_name + 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 + + if over_length > 0 + table_name = table_name[0, table_name.length - over_length] + end + + "#{table_name}_#{column_name}_#{suffix}" + end end 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 705e6063dc..6bd6b67165 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -7,30 +9,6 @@ module ActiveRecord PostgreSQL::ExplainPrettyPrinter.new.pp(exec_query(sql, "EXPLAIN", binds)) end - def select_value(arel, name = nil, binds = []) # :nodoc: - select_result(arel, name, binds) do |result| - result.getvalue(0, 0) if result.ntuples > 0 && result.nfields > 0 - end - end - - def select_values(arel, name = nil, binds = []) # :nodoc: - select_result(arel, name, binds) do |result| - if result.nfields > 0 - result.column_values(0) - else - [] - end - end - end - - # Executes a SELECT query and returns an array of rows. Each row is an - # array of field values. - def select_rows(arel, name = nil, binds = []) # :nodoc: - select_result(arel, name, binds) do |result| - result.values - end - end - # The internal PostgreSQL identifier of the money data type. MONEY_COLUMN_TYPE_OID = 790 #:nodoc: # The internal PostgreSQL identifier of the BYTEA data type. @@ -80,6 +58,8 @@ module ActiveRecord # Queries the database and returns the results in an Array-like object def query(sql, name = nil) #:nodoc: + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do result_as_array @connection.async_exec(sql) @@ -92,6 +72,8 @@ module ActiveRecord # Note: the PG::Result object is manually memory managed; if you don't # need it specifically, you may want consider the <tt>exec_query</tt> wrapper. def execute(sql, name = nil) + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @connection.async_exec(sql) @@ -171,18 +153,14 @@ module ActiveRecord end private + # Returns the current ID of a table's sequence. + def last_insert_id_result(sequence_name) + exec_query("SELECT currval(#{quote(sequence_name)})", "SQL") + end def suppress_composite_primary_key(pk) pk unless pk.is_a?(Array) end - - def select_result(arel, name, binds) - arel, binds = binds_from_relation(arel, binds) - sql = to_sql(arel, binds) - execute_and_clear(sql, name, binds) do |result| - yield result - end - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/explain_pretty_printer.rb b/activerecord/lib/active_record/connection_adapters/postgresql/explain_pretty_printer.rb index 99f3a5bbdf..086a5dcc15 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/explain_pretty_printer.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/explain_pretty_printer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb index 4098250f3e..247a25054e 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb @@ -1,14 +1,16 @@ +# frozen_string_literal: true + require "active_record/connection_adapters/postgresql/oid/array" require "active_record/connection_adapters/postgresql/oid/bit" require "active_record/connection_adapters/postgresql/oid/bit_varying" require "active_record/connection_adapters/postgresql/oid/bytea" require "active_record/connection_adapters/postgresql/oid/cidr" +require "active_record/connection_adapters/postgresql/oid/date" require "active_record/connection_adapters/postgresql/oid/date_time" require "active_record/connection_adapters/postgresql/oid/decimal" require "active_record/connection_adapters/postgresql/oid/enum" require "active_record/connection_adapters/postgresql/oid/hstore" require "active_record/connection_adapters/postgresql/oid/inet" -require "active_record/connection_adapters/postgresql/oid/json" require "active_record/connection_adapters/postgresql/oid/jsonb" require "active_record/connection_adapters/postgresql/oid/money" require "active_record/connection_adapters/postgresql/oid/oid" diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb index e1a75f8e5e..26abeea7ed 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -23,7 +25,7 @@ module ActiveRecord when ::String type_cast_array(@pg_decoder.decode(value), :deserialize) when Data - deserialize(value.values) + type_cast_array(value.values, :deserialize) else super end @@ -64,6 +66,10 @@ module ActiveRecord deserialize(raw_old_value) != new_value end + def force_equality?(value) + value.is_a?(::Array) + end + private def type_cast_array(value, method) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit.rb index 0a505f46a7..e9a79526f9 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -41,10 +43,7 @@ module ActiveRecord /\A[0-9A-F]*\Z/i.match?(value) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :value end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit_varying.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit_varying.rb index 4c21097d48..dc7079dda2 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit_varying.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bit_varying.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb index 8f9d6e7f9b..a3c60ecef6 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -6,7 +8,7 @@ module ActiveRecord def deserialize(value) return if value.nil? return value.to_s if value.is_a?(Type::Binary::Data) - PGconn.unescape_bytea(super) + PG::Connection.unescape_bytea(super) end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/cidr.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/cidr.rb index 5225609e37..66e99d9404 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/cidr.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/cidr.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "ipaddr" module ActiveRecord diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/date.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/date.rb new file mode 100644 index 0000000000..24a1daa95a --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/date.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module PostgreSQL + module OID # :nodoc: + class Date < Type::Date # :nodoc: + def cast_value(value) + case value + when "infinity" then ::Float::INFINITY + when "-infinity" then -::Float::INFINITY + when / BC$/ + astronomical_year = format("%04d", -value[/^\d+/].to_i + 1) + super(value.sub(/ BC$/, "").sub(/^\d+/, astronomical_year)) + else + super + end + end + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/date_time.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/date_time.rb index b7acbf7178..cd667422f5 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/date_time.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/date_time.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/decimal.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/decimal.rb index 43d22c8daf..e7d33855c4 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/decimal.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/decimal.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL module OID # :nodoc: class Decimal < Type::Decimal # :nodoc: def infinity(options = {}) - BigDecimal.new("Infinity") * (options[:negative] ? -1 : 1) + BigDecimal("Infinity") * (options[:negative] ? -1 : 1) end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/enum.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/enum.rb index 950d23d516..f70f09ad95 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/enum.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/enum.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb index 49dd4fc73f..aabe83b85d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/inet.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/inet.rb index 96486fa65b..55be71fd26 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/inet.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/inet.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/json.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/json.rb deleted file mode 100644 index dbc879ffd4..0000000000 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/json.rb +++ /dev/null @@ -1,10 +0,0 @@ -module ActiveRecord - module ConnectionAdapters - module PostgreSQL - module OID # :nodoc: - class Json < Type::Internal::AbstractJson - end - end - end - end -end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/jsonb.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/jsonb.rb index 87391b5dc7..e0216f1089 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/jsonb.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/jsonb.rb @@ -1,21 +1,13 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL module OID # :nodoc: - class Jsonb < Json # :nodoc: + class Jsonb < Type::Json # :nodoc: def type :jsonb end - - def changed_in_place?(raw_old_value, new_value) - # Postgres does not preserve insignificant whitespaces when - # round-tripping jsonb columns. This causes some false positives for - # the comparison here. Therefore, we need to parse and re-dump the - # raw value here to ensure the insignificant whitespaces are - # consistent with our encoder's output. - raw_old_value = serialize(deserialize(raw_old_value)) - super(raw_old_value, new_value) - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb index 775eecaf85..7b057a8452 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/money.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/money.rb index 7a91272d1c..6434377b57 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/money.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/money.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -22,7 +24,7 @@ module ActiveRecord # (3) -$2.55 # (4) ($2.55) - value.sub!(/^\((.+)\)$/, '-\1') # (4) + value = value.sub(/^\((.+)\)$/, '-\1') # (4) case value when /^-?\D+[\d,]+\.\d{2}$/ # (1) value.gsub!(/[^-\d.]/, "") diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/oid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/oid.rb index 9c2ac08b30..d8c044320d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/oid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/oid.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb index 7c764e7287..02a9c506f6 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord Point = Struct.new(:x, :y) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb index 54d5d0902e..d85f9ab3ef 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -33,7 +35,7 @@ module ActiveRecord if value.is_a?(::Range) from = type_cast_single_for_database(value.begin) to = type_cast_single_for_database(value.end) - "[#{from},#{to}#{value.exclude_end? ? ')' : ']'}" + ::Range.new(from, to, value.exclude_end?) else super end @@ -51,6 +53,10 @@ module ActiveRecord ::Range.new(new_begin, new_end, value.exclude_end?) end + def force_equality?(value) + value.is_a?(::Range) + end + private def type_cast_single(value) @@ -58,7 +64,7 @@ module ActiveRecord end def type_cast_single_for_database(value) - infinity?(value) ? "" : @subtype.serialize(value) + infinity?(value) ? value : @subtype.serialize(value) end def extract_bounds(value) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/specialized_string.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/specialized_string.rb index 564e82a4ac..4ad1344f05 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/specialized_string.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/specialized_string.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb index d9ae1aa7a2..79351bc3a4 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/extract" + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -14,12 +18,12 @@ module ActiveRecord def run(records) nodes = records.reject { |row| @store.key? row["oid"].to_i } - mapped, nodes = nodes.partition { |row| @store.key? row["typname"] } - ranges, nodes = nodes.partition { |row| row["typtype"] == "r".freeze } - enums, nodes = nodes.partition { |row| row["typtype"] == "e".freeze } - domains, nodes = nodes.partition { |row| row["typtype"] == "d".freeze } - arrays, nodes = nodes.partition { |row| row["typinput"] == "array_in".freeze } - composites, nodes = nodes.partition { |row| row["typelem"].to_i != 0 } + mapped = nodes.extract! { |row| @store.key? row["typname"] } + ranges = nodes.extract! { |row| row["typtype"] == "r".freeze } + enums = nodes.extract! { |row| row["typtype"] == "e".freeze } + domains = nodes.extract! { |row| row["typtype"] == "d".freeze } + arrays = nodes.extract! { |row| row["typinput"] == "array_in".freeze } + composites = nodes.extract! { |row| row["typelem"].to_i != 0 } mapped.each { |row| register_mapped_type(row) } enums.each { |row| register_enum_type(row) } @@ -29,8 +33,8 @@ module ActiveRecord composites.each { |row| register_composite_type(row) } end - def query_conditions_for_initial_load(type_map) - known_type_names = type_map.keys.map { |n| "'#{n}'" } + def query_conditions_for_initial_load + known_type_names = @store.keys.map { |n| "'#{n}'" } known_type_types = %w('r' 'e' 'd') <<-SQL % [known_type_names.join(", "), known_type_types.join(", ")] WHERE diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb index 5e839228e9..bc9b8dbfcf 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL module OID # :nodoc: class Uuid < Type::Value # :nodoc: - ACCEPTABLE_UUID = %r{\A\{?([a-fA-F0-9]{4}-?){8}\}?\z}x + ACCEPTABLE_UUID = %r{\A(\{)?([a-fA-F0-9]{4}-?){8}(?(1)\}|)\z} alias_method :serialize, :deserialize diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/vector.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/vector.rb index b26e876b54..88ef626a16 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/vector.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/vector.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/xml.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/xml.rb index d40d837cee..042f32fdc3 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/xml.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/xml.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb index 6663448a99..e75202b0be 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -33,7 +35,7 @@ module ActiveRecord # Quotes schema names for use in SQL queries. def quote_schema_name(name) - PGconn.quote_ident(name) + PG::Connection.quote_ident(name) end def quote_table_name_for_assignment(table, attr) @@ -42,7 +44,7 @@ module ActiveRecord # Quotes column names for use in SQL queries. def quote_column_name(name) # :nodoc: - @quoted_column_names[name] ||= PGconn.quote_ident(super).freeze + @quoted_column_names[name] ||= PG::Connection.quote_ident(super).freeze end # Quote date/time values for use in SQL input. @@ -62,7 +64,7 @@ module ActiveRecord def quote_default_expression(value, column) # :nodoc: if value.is_a?(Proc) value.call - elsif column.type == :uuid && value.include?("()") + elsif column.type == :uuid && value.is_a?(String) && /\(\)/.match?(value) value # Does not quote function default values for UUID columns elsif column.respond_to?(:array?) value = type_cast_from_column(column, value) @@ -77,6 +79,9 @@ module ActiveRecord end private + def lookup_cast_type(sql_type) + super(query_value("SELECT #{quote(sql_type)}::regtype::oid", "SCHEMA").to_i) + end def _quote(value) case value @@ -96,6 +101,8 @@ module ActiveRecord end when OID::Array::Data _quote(encode_array(value)) + when Range + _quote(encode_range(value)) else super end @@ -105,13 +112,15 @@ module ActiveRecord case value when Type::Binary::Data # Return a bind param hash with format as binary. - # See http://deveiate.org/code/pg/PGconn.html#method-i-exec_prepared-doc + # See https://deveiate.org/code/pg/PG/Connection.html#method-i-exec_prepared-doc # for more information { value: value.to_s, format: 1 } when OID::Xml::Data, OID::Bit::Data value.to_s when OID::Array::Data encode_array(value) + when Range + encode_range(value) else super end @@ -128,6 +137,10 @@ module ActiveRecord result end + def encode_range(range) + "[#{type_cast_range_value(range.first)},#{type_cast_range_value(range.last)}#{range.exclude_end? ? ')' : ']'}" + end + def determine_encoding_of_strings_in_array(value) case value when ::Array then determine_encoding_of_strings_in_array(value.first) @@ -141,6 +154,14 @@ module ActiveRecord else _type_cast(values) end end + + def type_cast_range_value(value) + infinity?(value) ? "" : type_cast(value) + end + + def infinity?(value) + value.respond_to?(:infinite?) && value.infinite? + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb b/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb index 44a7338bf5..8df91c988b 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb @@ -1,27 +1,24 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL module ReferentialIntegrity # :nodoc: - def supports_disable_referential_integrity? # :nodoc: - true - end - def disable_referential_integrity # :nodoc: - if supports_disable_referential_integrity? - original_exception = nil + original_exception = nil - begin - transaction(requires_new: true) do - execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(";")) - end - rescue ActiveRecord::ActiveRecordError => e - original_exception = e + begin + transaction(requires_new: true) do + execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(";")) end + rescue ActiveRecord::ActiveRecordError => e + original_exception = e + end - begin - yield - rescue ActiveRecord::InvalidForeignKey => e - warn <<-WARNING + begin + yield + rescue ActiveRecord::InvalidForeignKey => e + warn <<-WARNING WARNING: Rails was not able to disable referential integrity. This is most likely caused due to missing permissions. @@ -30,17 +27,14 @@ Rails needs superuser privileges to disable referential integrity. cause: #{original_exception.try(:message)} WARNING - raise e - end + raise e + end - begin - transaction(requires_new: true) do - execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(";")) - end - rescue ActiveRecord::ActiveRecordError + begin + transaction(requires_new: true) do + execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(";")) end - else - yield + rescue ActiveRecord::ActiveRecordError end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb index e1d5089115..8e381a92cf 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb @@ -1,8 +1,22 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL class SchemaCreation < AbstractAdapter::SchemaCreation # :nodoc: private + def visit_AlterTable(o) + super << o.constraint_validations.map { |fk| visit_ValidateConstraint fk }.join(" ") + end + + def visit_AddForeignKey(o) + super.dup.tap { |sql| sql << " NOT VALID" unless o.validate? } + end + + def visit_ValidateConstraint(name) + "VALIDATE CONSTRAINT #{quote_column_name(name)}" + end + def add_column_options!(sql, options) if options[:collation] sql << " COLLATE \"#{options[:collation]}\"" diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb index 11ea1e5144..206b855a18 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -11,10 +13,10 @@ module ActiveRecord # t.timestamps # end # - # By default, this will use the +gen_random_uuid()+ function from the + # By default, this will use the <tt>gen_random_uuid()</tt> function from the # +pgcrypto+ extension. As that extension is only available in # PostgreSQL 9.4+, for earlier versions an explicit default can be set - # to use +uuid_generate_v4()+ from the +uuid-ossp+ extension instead: + # to use <tt>uuid_generate_v4()</tt> from the +uuid-ossp+ extension instead: # # create_table :stuffs, id: false do |t| # t.primary_key :id, :uuid, default: "uuid_generate_v4()" @@ -42,15 +44,8 @@ module ActiveRecord # a record (as primary keys cannot be +nil+). This might be done via the # +SecureRandom.uuid+ method and a +before_save+ callback, for instance. def primary_key(name, type = :primary_key, **options) - options[:auto_increment] = true if [:integer, :bigint].include?(type) && !options.key?(:default) if type == :uuid options[:default] = options.fetch(:default, "gen_random_uuid()") - elsif options.delete(:auto_increment) == true && %i(integer bigint).include?(type) - type = if type == :bigint || options[:limit] == 8 - :bigserial - else - :serial - end end super @@ -100,10 +95,6 @@ module ActiveRecord args.each { |name| column(name, :int8range, options) } end - def json(*args, **options) - args.each { |name| column(name, :json, options) } - end - def jsonb(*args, **options) args.each { |name| column(name, :jsonb, options) } end @@ -183,11 +174,33 @@ module ActiveRecord class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition include ColumnMethods + + private + def integer_like_primary_key_type(type, options) + if type == :bigint || options[:limit] == 8 + :bigserial + else + :serial + end + end end class Table < ActiveRecord::ConnectionAdapters::Table include ColumnMethods end + + class AlterTable < ActiveRecord::ConnectionAdapters::AlterTable + attr_reader :constraint_validations + + def initialize(td) + super + @constraint_validations = [] + end + + def validate_constraint(name) + @constraint_validations << name + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb index 5555a46b6b..84643d20da 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb @@ -1,21 +1,28 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL - module ColumnDumper # :nodoc: - # Adds +:array+ option to the default set - def prepare_column_options(column) - spec = super - spec[:array] = "true" if column.array? - spec - end - - # Adds +:array+ as a valid migration key - def migration_keys - super + [:array] - end - + class SchemaDumper < ConnectionAdapters::SchemaDumper # :nodoc: private + def extensions(stream) + extensions = @connection.extensions + if extensions.any? + stream.puts " # These are extensions that must be enabled in order to support this database" + extensions.sort.each do |extension| + stream.puts " enable_extension #{extension.inspect}" + end + stream.puts + end + end + + def prepare_column_options(column) + spec = super + spec[:array] = "true" if column.array? + spec + end + def default_primary_key?(column) schema_type(column) == :bigserial end 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 afef0da5c7..00da7690a2 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -1,4 +1,4 @@ -require "active_support/core_ext/string/strip" +# frozen_string_literal: true module ActiveRecord module ConnectionAdapters @@ -38,7 +38,7 @@ module ActiveRecord " TABLESPACE = \"#{value}\"" when :connection_limit " CONNECTION LIMIT = #{value}" - else + else "" end end @@ -54,130 +54,48 @@ module ActiveRecord execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}" end - # Returns the list of all tables in the schema search path. - def tables - select_values("SELECT tablename FROM pg_tables WHERE schemaname = ANY(current_schemas(false))", "SCHEMA") - end - - def data_sources # :nodoc - select_values(<<-SQL, "SCHEMA") - SELECT c.relname - FROM pg_class c - LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r','v','m') -- (r)elation/table, (v)iew, (m)aterialized view - AND n.nspname = ANY (current_schemas(false)) - SQL - end - - def views # :nodoc: - select_values(<<-SQL, "SCHEMA") - SELECT c.relname - FROM pg_class c - LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('v','m') -- (v)iew, (m)aterialized view - AND n.nspname = ANY (current_schemas(false)) - SQL - end - - # Returns true if table exists. - # If the schema is not specified as part of +name+ then it will only find tables within - # the current schema search path (regardless of permissions to access tables in other schemas) - def table_exists?(name) - name = Utils.extract_schema_qualified_name(name.to_s) - return false unless name.identifier - - select_values(<<-SQL, "SCHEMA").any? - SELECT tablename - FROM pg_tables - WHERE tablename = #{quote(name.identifier)} - AND schemaname = #{name.schema ? quote(name.schema) : "ANY (current_schemas(false))"} - SQL - end - - def data_source_exists?(name) # :nodoc: - name = Utils.extract_schema_qualified_name(name.to_s) - return false unless name.identifier - - select_values(<<-SQL, "SCHEMA").any? - SELECT c.relname - FROM pg_class c - LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r','v','m') -- (r)elation/table, (v)iew, (m)aterialized view - AND c.relname = #{quote(name.identifier)} - AND n.nspname = #{name.schema ? quote(name.schema) : "ANY (current_schemas(false))"} - SQL - end - - def view_exists?(view_name) # :nodoc: - name = Utils.extract_schema_qualified_name(view_name.to_s) - return false unless name.identifier - - select_values(<<-SQL, "SCHEMA").any? - SELECT c.relname - FROM pg_class c - LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('v','m') -- (v)iew, (m)aterialized view - AND c.relname = #{quote(name.identifier)} - AND n.nspname = #{name.schema ? quote(name.schema) : "ANY (current_schemas(false))"} - SQL - end - def drop_table(table_name, options = {}) # :nodoc: execute "DROP TABLE#{' IF EXISTS' if options[:if_exists]} #{quote_table_name(table_name)}#{' CASCADE' if options[:force] == :cascade}" end # Returns true if schema exists. def schema_exists?(name) - select_value("SELECT COUNT(*) FROM pg_namespace WHERE nspname = '#{name}'", "SCHEMA").to_i > 0 + query_value("SELECT COUNT(*) FROM pg_namespace WHERE nspname = #{quote(name)}", "SCHEMA").to_i > 0 end # Verifies existence of an index with a given name. - def index_name_exists?(table_name, index_name, default = nil) - unless default.nil? - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Passing default to #index_name_exists? is deprecated without replacement. - MSG - end - table = Utils.extract_schema_qualified_name(table_name.to_s) - index = Utils.extract_schema_qualified_name(index_name.to_s) + def index_name_exists?(table_name, index_name) + table = quoted_scope(table_name) + index = quoted_scope(index_name) - select_value(<<-SQL, "SCHEMA").to_i > 0 + query_value(<<-SQL, "SCHEMA").to_i > 0 SELECT COUNT(*) FROM pg_class t INNER JOIN pg_index d ON t.oid = d.indrelid INNER JOIN pg_class i ON d.indexrelid = i.oid LEFT JOIN pg_namespace n ON n.oid = i.relnamespace WHERE i.relkind = 'i' - AND i.relname = '#{index.identifier}' - AND t.relname = '#{table.identifier}' - AND n.nspname = #{index.schema ? "'#{index.schema}'" : 'ANY (current_schemas(false))'} + AND i.relname = #{index[:name]} + AND t.relname = #{table[:name]} + AND n.nspname = #{index[:schema]} SQL end # Returns an array of indexes for the given table. - def indexes(table_name, name = nil) # :nodoc: - if name - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Passing name to #indexes is deprecated without replacement. - MSG - end - - table = Utils.extract_schema_qualified_name(table_name.to_s) + def indexes(table_name) # :nodoc: + scope = quoted_scope(table_name) result = query(<<-SQL, "SCHEMA") SELECT distinct i.relname, d.indisunique, d.indkey, pg_get_indexdef(d.indexrelid), t.oid, - pg_catalog.obj_description(i.oid, 'pg_class') AS comment, - (SELECT COUNT(*) FROM pg_opclass o - JOIN (SELECT unnest(string_to_array(d.indclass::text, ' '))::int oid) c - ON o.oid = c.oid WHERE o.opcdefault = 'f') + pg_catalog.obj_description(i.oid, 'pg_class') AS comment FROM pg_class t INNER JOIN pg_index d ON t.oid = d.indrelid INNER JOIN pg_class i ON d.indexrelid = i.oid LEFT JOIN pg_namespace n ON n.oid = i.relnamespace WHERE i.relkind = 'i' AND d.indisprimary = 'f' - AND t.relname = '#{table.identifier}' - AND n.nspname = #{table.schema ? "'#{table.schema}'" : 'ANY (current_schemas(false))'} + AND t.relname = #{scope[:name]} + AND n.nspname = #{scope[:schema]} ORDER BY i.relname SQL @@ -188,47 +106,46 @@ module ActiveRecord inddef = row[3] oid = row[4] comment = row[5] - opclass = row[6] - using, expressions, where = inddef.scan(/ USING (\w+?) \((.+?)\)(?: WHERE (.+))?\z/).flatten + using, expressions, where = inddef.scan(/ USING (\w+?) \((.+?)\)(?: WHERE (.+))?\z/m).flatten - if indkey.include?(0) || opclass > 0 + orders = {} + opclasses = {} + + if indkey.include?(0) columns = expressions else - columns = Hash[query(<<-SQL.strip_heredoc, "SCHEMA")].values_at(*indkey).compact + columns = Hash[query(<<~SQL, "SCHEMA")].values_at(*indkey).compact SELECT a.attnum, a.attname FROM pg_attribute a WHERE a.attrelid = #{oid} AND a.attnum IN (#{indkey.join(",")}) SQL - # add info on sort order for columns (only desc order is explicitly specified, asc is the default) - orders = Hash[ - expressions.scan(/(\w+) DESC/).flatten.map { |order_column| [order_column, :desc] } - ] + # add info on sort order (only desc order is explicitly specified, asc is the default) + # and non-default opclasses + expressions.scan(/(?<column>\w+)\s?(?<opclass>\w+_ops)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/).each do |column, opclass, desc, nulls| + opclasses[column] = opclass.to_sym if opclass + if nulls + orders[column] = [desc, nulls].compact.join(" ") + else + orders[column] = :desc if desc + end + end end - IndexDefinition.new(table_name, index_name, unique, columns, [], orders, where, nil, using.to_sym, comment.presence) - end.compact - end - - def new_column_from_field(table_name, field) # :nondoc: - column_name, type, default, notnull, oid, fmod, collation, comment = field - oid = oid.to_i - fmod = fmod.to_i - type_metadata = fetch_type_metadata(column_name, type, oid, fmod) - default_value = extract_value_from_default(default) - default_function = extract_default_function(default_value, default) - PostgreSQLColumn.new( - column_name, - default_value, - type_metadata, - !notnull, - table_name, - default_function, - collation, - comment: comment.presence - ) + IndexDefinition.new( + table_name, + index_name, + unique, + columns, + orders: orders, + opclasses: opclasses, + where: where, + using: using.to_sym, + comment: comment.presence + ) + end end def table_options(table_name) # :nodoc: @@ -239,47 +156,47 @@ module ActiveRecord # Returns a comment stored in database for given table def table_comment(table_name) # :nodoc: - name = Utils.extract_schema_qualified_name(table_name.to_s) - if name.identifier - select_value(<<-SQL.strip_heredoc, "SCHEMA") + scope = quoted_scope(table_name, type: "BASE TABLE") + if scope[:name] + query_value(<<~SQL, "SCHEMA") SELECT pg_catalog.obj_description(c.oid, 'pg_class') FROM pg_catalog.pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = #{quote(name.identifier)} - AND c.relkind IN ('r') -- (r)elation/table - AND n.nspname = #{name.schema ? quote(name.schema) : 'ANY (current_schemas(false))'} + WHERE c.relname = #{scope[:name]} + AND c.relkind IN (#{scope[:type]}) + AND n.nspname = #{scope[:schema]} SQL end end # Returns the current database name. def current_database - select_value("select current_database()", "SCHEMA") + query_value("SELECT current_database()", "SCHEMA") end # Returns the current schema name. def current_schema - select_value("SELECT current_schema", "SCHEMA") + query_value("SELECT current_schema", "SCHEMA") end # Returns the current database encoding format. def encoding - select_value("SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname LIKE '#{current_database}'", "SCHEMA") + query_value("SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()", "SCHEMA") end # Returns the current database collation. def collation - select_value("SELECT datcollate FROM pg_database WHERE datname LIKE '#{current_database}'", "SCHEMA") + query_value("SELECT datcollate FROM pg_database WHERE datname = current_database()", "SCHEMA") end # Returns the current database ctype. def ctype - select_value("SELECT datctype FROM pg_database WHERE datname LIKE '#{current_database}'", "SCHEMA") + query_value("SELECT datctype FROM pg_database WHERE datname = current_database()", "SCHEMA") end # Returns an array of schema names. def schema_names - select_values(<<-SQL, "SCHEMA") + query_values(<<-SQL, "SCHEMA") SELECT nspname FROM pg_namespace WHERE nspname !~ '^pg_.*' @@ -300,7 +217,7 @@ module ActiveRecord # Sets the schema search path to a string of comma-separated schema names. # Names beginning with $ have to be quoted (e.g. $user => '$user'). - # See: http://www.postgresql.org/docs/current/static/ddl-schemas.html + # See: https://www.postgresql.org/docs/current/static/ddl-schemas.html # # This should be not be called manually but set in database.yml. def schema_search_path=(schema_csv) @@ -312,12 +229,12 @@ module ActiveRecord # Returns the active schema search path. def schema_search_path - @schema_search_path ||= select_value("SHOW search_path", "SCHEMA") + @schema_search_path ||= query_value("SHOW search_path", "SCHEMA") end # Returns the current client message level. def client_min_messages - select_value("SHOW client_min_messages", "SCHEMA") + query_value("SHOW client_min_messages", "SCHEMA") end # Set the client message level. @@ -335,7 +252,7 @@ module ActiveRecord end def serial_sequence(table, column) - select_value("SELECT pg_get_serial_sequence('#{table}', '#{column}')", "SCHEMA") + query_value("SELECT pg_get_serial_sequence(#{quote(table)}, #{quote(column)})", "SCHEMA") end # Sets the sequence of a table's primary key to the specified value. @@ -346,7 +263,7 @@ module ActiveRecord if sequence quoted_sequence = quote_table_name(sequence) - select_value("SELECT setval('#{quoted_sequence}', #{value})", "SCHEMA") + query_value("SELECT setval(#{quote(quoted_sequence)}, #{value})", "SCHEMA") else @logger.warn "#{table} has primary key #{pk} with no default sequence." if @logger end @@ -368,10 +285,16 @@ module ActiveRecord if pk && sequence 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 + 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") + end + end - select_value(<<-end_sql, "SCHEMA") - SELECT setval('#{quoted_sequence}', (SELECT COALESCE(MAX(#{quote_column_name pk})+(SELECT increment_by FROM #{quoted_sequence}), (SELECT min_value FROM #{quoted_sequence})) FROM #{quote_table_name(table)}), false) - end_sql + query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})", "SCHEMA") end end @@ -395,7 +318,7 @@ module ActiveRecord AND seq.relnamespace = nsp.oid AND cons.contype = 'p' AND dep.classid = 'pg_class'::regclass - AND dep.refobjid = '#{quote_table_name(table)}'::regclass + AND dep.refobjid = #{quote(quote_table_name(table))}::regclass end_sql if result.nil? || result.empty? @@ -413,7 +336,7 @@ module ActiveRecord JOIN pg_attrdef def ON (adrelid = attrelid AND adnum = attnum) JOIN pg_constraint cons ON (conrelid = adrelid AND adnum = conkey[1]) JOIN pg_namespace nsp ON (t.relnamespace = nsp.oid) - WHERE t.oid = '#{quote_table_name(table)}'::regclass + WHERE t.oid = #{quote(quote_table_name(table))}::regclass AND cons.contype = 'p' AND pg_get_expr(def.adbin, def.adrelid) ~* 'nextval|uuid_generate' end_sql @@ -430,21 +353,46 @@ module ActiveRecord end def primary_keys(table_name) # :nodoc: - name = Utils.extract_schema_qualified_name(table_name.to_s) - select_values(<<-SQL.strip_heredoc, "SCHEMA") - SELECT column_name - FROM information_schema.key_column_usage kcu - JOIN information_schema.table_constraints tc - ON kcu.table_name = tc.table_name - AND kcu.table_schema = tc.table_schema - AND kcu.constraint_name = tc.constraint_name - WHERE constraint_type = 'PRIMARY KEY' - AND kcu.table_name = #{quote(name.identifier)} - AND kcu.table_schema = #{name.schema ? quote(name.schema) : "ANY (current_schemas(false))"} - ORDER BY kcu.ordinal_position + query_values(<<~SQL, "SCHEMA") + SELECT a.attname + FROM ( + SELECT indrelid, indkey, generate_subscripts(indkey, 1) idx + FROM pg_index + WHERE indrelid = #{quote(quote_table_name(table_name))}::regclass + AND indisprimary + ) i + JOIN pg_attribute a + ON a.attrelid = i.indrelid + AND a.attnum = i.indkey[i.idx] + ORDER BY i.idx 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. @@ -455,14 +403,15 @@ module ActiveRecord clear_cache! execute "ALTER TABLE #{quote_table_name(table_name)} RENAME TO #{quote_table_name(new_name)}" pk, seq = pk_and_sequence_for(new_name) - if seq && seq.identifier == "#{table_name}_#{pk}_seq" - new_seq = "#{new_name}_#{pk}_seq" + if pk idx = "#{table_name}_pkey" new_idx = "#{new_name}_pkey" - execute "ALTER TABLE #{seq.quoted} RENAME TO #{quote_table_name(new_seq)}" execute "ALTER INDEX #{quote_table_name(idx)} RENAME TO #{quote_table_name(new_idx)}" + if seq && seq.identifier == "#{table_name}_#{pk}_seq" + new_seq = "#{new_name}_#{pk}_seq" + execute "ALTER TABLE #{seq.quoted} RENAME TO #{quote_table_name(new_seq)}" + end end - rename_table_indexes(table_name, new_name) end @@ -474,50 +423,23 @@ module ActiveRecord def change_column(table_name, column_name, type, options = {}) #:nodoc: clear_cache! - quoted_table_name = quote_table_name(table_name) - quoted_column_name = quote_column_name(column_name) - sql_type = type_to_sql(type, options) - sql = "ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quoted_column_name} TYPE #{sql_type}" - if options[:collation] - sql << " COLLATE \"#{options[:collation]}\"" - end - if options[:using] - sql << " USING #{options[:using]}" - elsif options[:cast_as] - cast_as_type = type_to_sql(options[:cast_as], options) - sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})" - end - execute sql - - change_column_default(table_name, column_name, options[:default]) if options.key?(:default) - change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) - change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) + sqls, procs = Array(change_column_for_alter(table_name, column_name, type, options)).partition { |v| v.is_a?(String) } + execute "ALTER TABLE #{quote_table_name(table_name)} #{sqls.join(", ")}" + procs.each(&:call) end # Changes the default value of a table column. def change_column_default(table_name, column_name, default_or_changes) # :nodoc: - clear_cache! - column = column_for(table_name, column_name) - return unless column - - default = extract_new_default_value(default_or_changes) - alter_column_query = "ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} %s" - if default.nil? - # <tt>DEFAULT NULL</tt> results in the same behavior as <tt>DROP DEFAULT</tt>. However, PostgreSQL will - # cast the default to the columns type, which leaves us with a default like "default NULL::character varying". - execute alter_column_query % "DROP DEFAULT" - else - execute alter_column_query % "SET DEFAULT #{quote_default_expression(default, column)}" - end + execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_default_for_alter(table_name, column_name, default_or_changes)}" end def change_column_null(table_name, column_name, null, default = nil) #:nodoc: clear_cache! unless null || default.nil? column = column_for(table_name, column_name) - execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_expression(default, column)} WHERE #{quote_column_name(column_name)} IS NULL") if column + execute "UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_expression(default, column)} WHERE #{quote_column_name(column_name)} IS NULL" if column end - execute("ALTER TABLE #{quote_table_name(table_name)} ALTER #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL") + execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_null_for_alter(table_name, column_name, null, default)}" end # Adds comment for given table column or drops it if +comment+ is a +nil+ @@ -540,8 +462,8 @@ module ActiveRecord end def add_index(table_name, column_name, options = {}) #:nodoc: - index_name, index_type, index_columns, index_options, index_algorithm, index_using, comment = add_index_options(table_name, column_name, options) - execute("CREATE #{index_type} INDEX #{index_algorithm} #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} #{index_using} (#{index_columns})#{index_options}").tap do + index_name, index_type, index_columns_and_opclasses, index_options, index_algorithm, index_using, comment = add_index_options(table_name, column_name, options) + execute("CREATE #{index_type} INDEX #{index_algorithm} #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} #{index_using} (#{index_columns_and_opclasses})#{index_options}").tap do execute "COMMENT ON INDEX #{quote_column_name(index_name)} IS #{quote(comment)}" if comment end end @@ -579,8 +501,9 @@ module ActiveRecord end def foreign_keys(table_name) - fk_info = select_all(<<-SQL.strip_heredoc, "SCHEMA") - SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete + scope = quoted_scope(table_name) + fk_info = exec_query(<<~SQL, "SCHEMA") + SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid FROM pg_constraint c JOIN pg_class t1 ON c.conrelid = t1.oid JOIN pg_class t2 ON c.confrelid = t2.oid @@ -588,8 +511,8 @@ module ActiveRecord JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid JOIN pg_namespace t3 ON c.connamespace = t3.oid WHERE c.contype = 'f' - AND t1.relname = #{quote(table_name)} - AND t3.nspname = ANY (current_schemas(false)) + AND t1.relname = #{scope[:name]} + AND t3.nspname = #{scope[:schema]} ORDER BY c.conname SQL @@ -602,17 +525,18 @@ module ActiveRecord options[:on_delete] = extract_foreign_key_action(row["on_delete"]) options[:on_update] = extract_foreign_key_action(row["on_update"]) + options[:validate] = row["valid"] ForeignKeyDefinition.new(table_name, row["to_table"], options) end end - def extract_foreign_key_action(specifier) # :nodoc: - case specifier - when "c"; :cascade - when "n"; :nullify - when "r"; :restrict - end + def foreign_tables + query_values(data_source_sql(type: "FOREIGN TABLE"), "SCHEMA") + end + + def foreign_table_exists?(table_name) + query_values(data_source_sql(table_name, type: "FOREIGN TABLE"), "SCHEMA").any? if table_name.present? end # Maps logical Rails types to PostgreSQL-specific data types. @@ -644,7 +568,7 @@ module ActiveRecord super end - sql << "[]" if array && type != :primary_key + sql = "#{sql}[]" if array && type != :primary_key sql end @@ -659,20 +583,209 @@ module ActiveRecord .gsub(/\s+NULLS\s+(?:FIRST|LAST)\b/i, "") }.reject(&:blank?).map.with_index { |column, i| "#{column} AS alias_#{i}" } - [super, *order_columns].join(", ") + (order_columns << super).join(", ") + end + + def update_table_definition(table_name, base) # :nodoc: + PostgreSQL::Table.new(table_name, base) + end + + def create_schema_dumper(options) # :nodoc: + PostgreSQL::SchemaDumper.create(self, options) end - def fetch_type_metadata(column_name, sql_type, oid, fmod) - cast_type = get_oid_type(oid, fmod, column_name, sql_type) - simple_type = SqlTypeMetadata.new( - sql_type: sql_type, - type: cast_type.type, - limit: cast_type.limit, - precision: cast_type.precision, - scale: cast_type.scale, - ) - PostgreSQLTypeMetadata.new(simple_type, oid: oid, fmod: fmod) + # Validates the given constraint. + # + # Validates the constraint named +constraint_name+ on +accounts+. + # + # validate_constraint :accounts, :constraint_name + def validate_constraint(table_name, constraint_name) + return unless supports_validate_constraints? + + at = create_alter_table table_name + at.validate_constraint constraint_name + + execute schema_creation.accept(at) end + + # Validates the given foreign key. + # + # Validates the foreign key on +accounts.branch_id+. + # + # validate_foreign_key :accounts, :branches + # + # Validates the foreign key on +accounts.owner_id+. + # + # validate_foreign_key :accounts, column: :owner_id + # + # Validates the foreign key named +special_fk_name+ on the +accounts+ table. + # + # validate_foreign_key :accounts, name: :special_fk_name + # + # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. + def validate_foreign_key(from_table, options_or_to_table = {}) + return unless supports_validate_constraints? + + fk_name_to_validate = foreign_key_for!(from_table, options_or_to_table).name + + validate_constraint from_table, fk_name_to_validate + end + + private + def schema_creation + PostgreSQL::SchemaCreation.new(self) + end + + def create_table_definition(*args) + PostgreSQL::TableDefinition.new(*args) + end + + def create_alter_table(name) + PostgreSQL::AlterTable.new create_table_definition(name) + end + + def new_column_from_field(table_name, field) + column_name, type, default, notnull, oid, fmod, collation, comment = field + type_metadata = fetch_type_metadata(column_name, type, oid.to_i, fmod.to_i) + default_value = extract_value_from_default(default) + default_function = extract_default_function(default_value, default) + + PostgreSQLColumn.new( + column_name, + default_value, + type_metadata, + !notnull, + table_name, + default_function, + collation, + comment: comment.presence, + max_identifier_length: max_identifier_length + ) + end + + def fetch_type_metadata(column_name, sql_type, oid, fmod) + cast_type = get_oid_type(oid, fmod, column_name, sql_type) + simple_type = SqlTypeMetadata.new( + sql_type: sql_type, + type: cast_type.type, + limit: cast_type.limit, + precision: cast_type.precision, + scale: cast_type.scale, + ) + PostgreSQLTypeMetadata.new(simple_type, oid: oid, fmod: fmod) + end + + def extract_foreign_key_action(specifier) + case specifier + when "c"; :cascade + when "n"; :nullify + when "r"; :restrict + end + end + + def change_column_sql(table_name, column_name, type, options = {}) + quoted_column_name = quote_column_name(column_name) + sql_type = type_to_sql(type, options) + sql = "ALTER COLUMN #{quoted_column_name} TYPE #{sql_type}".dup + if options[:collation] + sql << " COLLATE \"#{options[:collation]}\"" + end + if options[:using] + sql << " USING #{options[:using]}" + elsif options[:cast_as] + cast_as_type = type_to_sql(options[:cast_as], options) + sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})" + end + + sql + end + + def add_column_for_alter(table_name, column_name, type, options = {}) + return super unless options.key?(:comment) + [super, Proc.new { change_column_comment(table_name, column_name, options[:comment]) }] + end + + def change_column_for_alter(table_name, column_name, type, options = {}) + sqls = [change_column_sql(table_name, column_name, type, options)] + sqls << change_column_default_for_alter(table_name, column_name, options[:default]) if options.key?(:default) + sqls << change_column_null_for_alter(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + sqls << Proc.new { change_column_comment(table_name, column_name, options[:comment]) } if options.key?(:comment) + sqls + end + + # Changes the default value of a table column. + def change_column_default_for_alter(table_name, column_name, default_or_changes) # :nodoc: + column = column_for(table_name, column_name) + return unless column + + default = extract_new_default_value(default_or_changes) + alter_column_query = "ALTER COLUMN #{quote_column_name(column_name)} %s" + if default.nil? + # <tt>DEFAULT NULL</tt> results in the same behavior as <tt>DROP DEFAULT</tt>. However, PostgreSQL will + # cast the default to the columns type, which leaves us with a default like "default NULL::character varying". + alter_column_query % "DROP DEFAULT" + else + alter_column_query % "SET DEFAULT #{quote_default_expression(default, column)}" + end + end + + def change_column_null_for_alter(table_name, column_name, null, default = nil) #:nodoc: + "ALTER #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL" + end + + def add_timestamps_for_alter(table_name, options = {}) + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] + end + + def remove_timestamps_for_alter(table_name, options = {}) + [remove_column_for_alter(table_name, :updated_at), remove_column_for_alter(table_name, :created_at)] + end + + def add_index_opclass(quoted_columns, **options) + opclasses = options_for_index_columns(options[:opclass]) + quoted_columns.each do |name, column| + column << " #{opclasses[name]}" if opclasses[name].present? + end + end + + def add_options_for_index_columns(quoted_columns, **options) + quoted_columns = add_index_opclass(quoted_columns, options) + super + end + + def data_source_sql(name = nil, type: nil) + scope = quoted_scope(name, type: type) + scope[:type] ||= "'r','v','m','p','f'" # (r)elation/table, (v)iew, (m)aterialized view, (p)artitioned table, (f)oreign table + + sql = "SELECT c.relname FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace".dup + sql << " WHERE n.nspname = #{scope[:schema]}" + sql << " AND c.relname = #{scope[:name]}" if scope[:name] + sql << " AND c.relkind IN (#{scope[:type]})" + sql + end + + def quoted_scope(name = nil, type: nil) + schema, name = extract_schema_qualified_name(name) + type = \ + case type + when "BASE TABLE" + "'r','p'" + when "VIEW" + "'v','m'" + when "FOREIGN TABLE" + "'f'" + end + scope = {} + scope[:schema] = schema ? quote(schema) : "ANY (current_schemas(false))" + scope[:name] = quote(name) if name + scope[:type] = type if type + scope + end + + def extract_schema_qualified_name(string) + name = Utils.extract_schema_qualified_name(string.to_s) + [name.schema, name.identifier] + end end end end 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 311988625f..ffd3be26b0 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb @@ -1,6 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord + # :stopdoc: module ConnectionAdapters class PostgreSQLTypeMetadata < DelegateClass(SqlTypeMetadata) + undef to_yaml if method_defined?(:to_yaml) + attr_reader :oid, :fmod, :array def initialize(type_metadata, oid: nil, fmod: nil) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb b/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb index a3f9ce6d64..bfd300723d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -19,9 +21,9 @@ module ActiveRecord def quoted if schema - PGconn.quote_ident(schema) << SEPARATOR << PGconn.quote_ident(identifier) + PG::Connection.quote_ident(schema) << SEPARATOR << PG::Connection.quote_ident(identifier) else - PGconn.quote_ident(identifier) + PG::Connection.quote_ident(identifier) 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 bc04565434..3ee344a249 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + # Make sure we're using pg high enough for type casts and Ruby 2.2+ compatibility -gem "pg", "~> 0.18" +gem "pg", ">= 0.18", "< 2.0" require "pg" require "active_record/connection_adapters/abstract_adapter" +require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/postgresql/column" require "active_record/connection_adapters/postgresql/database_statements" require "active_record/connection_adapters/postgresql/explain_pretty_printer" @@ -15,7 +18,6 @@ require "active_record/connection_adapters/postgresql/schema_dumper" require "active_record/connection_adapters/postgresql/schema_statements" require "active_record/connection_adapters/postgresql/type_metadata" require "active_record/connection_adapters/postgresql/utils" -require "active_record/connection_adapters/statement_pool" module ActiveRecord module ConnectionHandling # :nodoc: @@ -29,11 +31,11 @@ module ActiveRecord conn_params[:user] = conn_params.delete(:username) if conn_params[:username] conn_params[:dbname] = conn_params.delete(:database) if conn_params[:database] - # Forward only valid config params to PGconn.connect. - valid_conn_param_keys = PGconn.conndefaults_hash.keys + [:requiressl] + # Forward only valid config params to PG::Connection.connect. + valid_conn_param_keys = PG::Connection.conndefaults_hash.keys + [:requiressl] conn_params.slice!(*valid_conn_param_keys) - # The postgres drivers don't allow the creation of an unconnected PGconn object, + # The postgres drivers don't allow the creation of an unconnected PG::Connection object, # so just pass a nil connection object for the time being. ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config) end @@ -62,11 +64,11 @@ module ActiveRecord # defaults to true. # # Any further options are used as connection parameters to libpq. See - # http://www.postgresql.org/docs/current/static/libpq-connect.html for the + # https://www.postgresql.org/docs/current/static/libpq-connect.html for the # list of parameters. # # In addition, default connection parameters of libpq can be set per environment variables. - # See http://www.postgresql.org/docs/current/static/libpq-envars.html . + # See https://www.postgresql.org/docs/current/static/libpq-envars.html . class PostgreSQLAdapter < AbstractAdapter ADAPTER_NAME = "PostgreSQL".freeze @@ -74,7 +76,7 @@ module ActiveRecord primary_key: "bigserial primary key", string: { name: "character varying" }, text: { name: "text" }, - integer: { name: "integer" }, + integer: { name: "integer", limit: 4 }, float: { name: "float" }, decimal: { name: "decimal" }, datetime: { name: "timestamp" }, @@ -119,19 +121,8 @@ module ActiveRecord include PostgreSQL::ReferentialIntegrity include PostgreSQL::SchemaStatements include PostgreSQL::DatabaseStatements - include PostgreSQL::ColumnDumper - - def schema_creation # :nodoc: - PostgreSQL::SchemaCreation.new self - end - - def arel_visitor # :nodoc: - Arel::Visitors::PostgreSQL.new(self) - end - # Returns true, since this connection adapter supports prepared statement - # caching. - def supports_statement_cache? + def supports_bulk_alter? true end @@ -155,6 +146,10 @@ module ActiveRecord true end + def supports_validate_constraints? + true + end + def supports_views? true end @@ -179,7 +174,7 @@ module ActiveRecord { concurrently: "CONCURRENTLY" } end - class StatementPool < ConnectionAdapters::StatementPool + class StatementPool < ConnectionAdapters::StatementPool # :nodoc: def initialize(connection, max) super(max) @connection = connection @@ -195,14 +190,14 @@ module ActiveRecord end private - def dealloc(key) @connection.query "DEALLOCATE #{key}" if connection_active? + rescue PG::Error end def connection_active? - @connection.status == PGconn::CONNECTION_OK - rescue PGError + @connection.status == PG::CONNECTION_OK + rescue PG::Error false end end @@ -229,7 +224,7 @@ module ActiveRecord add_pg_decoders @type_map = Type::HashLookupTypeMap.new - initialize_type_map(type_map) + initialize_type_map @local_tz = execute("SHOW TIME ZONE", "SCHEMA").first["TimeZone"] @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true end @@ -247,34 +242,47 @@ module ActiveRecord # Is this connection alive and ready for queries? def active? - @connection.query "SELECT 1" + @lock.synchronize do + @connection.query "SELECT 1" + end true - rescue PGError + rescue PG::Error false end # Close then reopen the connection. def reconnect! - super - @connection.reset - configure_connection + @lock.synchronize do + super + @connection.reset + configure_connection + end end def reset! - clear_cache! - reset_transaction - unless @connection.transaction_status == ::PG::PQTRANS_IDLE - @connection.query "ROLLBACK" + @lock.synchronize do + clear_cache! + reset_transaction + unless @connection.transaction_status == ::PG::PQTRANS_IDLE + @connection.query "ROLLBACK" + end + @connection.query "DISCARD ALL" + configure_connection end - @connection.query "DISCARD ALL" - configure_connection end # Disconnects from the database if already connected. Otherwise, this # method does nothing. def disconnect! - super - @connection.close rescue nil + @lock.synchronize do + super + @connection.close rescue nil + end + end + + def discard! # :nodoc: + @connection.socket_io.reopen(IO::NULL) rescue nil + @connection = nil end def native_database_types #:nodoc: @@ -301,8 +309,8 @@ module ActiveRecord true end - # Range datatypes weren't introduced until PostgreSQL 9.2 def supports_ranges? + # Range datatypes weren't introduced until PostgreSQL 9.2 postgresql_version >= 90200 end @@ -310,22 +318,30 @@ module ActiveRecord postgresql_version >= 90300 end + def supports_foreign_tables? + postgresql_version >= 90300 + end + def supports_pgcrypto_uuid? postgresql_version >= 90400 end + def supports_lazy_transactions? + true + end + def get_advisory_lock(lock_id) # :nodoc: unless lock_id.is_a?(Integer) && lock_id.bit_length <= 63 - raise(ArgumentError, "Postgres requires advisory lock ids to be a signed 64 bit integer") + raise(ArgumentError, "PostgreSQL requires advisory lock ids to be a signed 64 bit integer") end - select_value("SELECT pg_try_advisory_lock(#{lock_id});") + query_value("SELECT pg_try_advisory_lock(#{lock_id})") end def release_advisory_lock(lock_id) # :nodoc: unless lock_id.is_a?(Integer) && lock_id.bit_length <= 63 - raise(ArgumentError, "Postgres requires advisory lock ids to be a signed 64 bit integer") + raise(ArgumentError, "PostgreSQL requires advisory lock ids to be a signed 64 bit integer") end - select_value("SELECT pg_advisory_unlock(#{lock_id})") + query_value("SELECT pg_advisory_unlock(#{lock_id})") end def enable_extension(name) @@ -341,46 +357,31 @@ module ActiveRecord end def extension_enabled?(name) - if supports_extensions? - res = exec_query "SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", - "SCHEMA" - res.cast_values.first - end + res = exec_query("SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", "SCHEMA") + res.cast_values.first end def extensions - if supports_extensions? - exec_query("SELECT extname from pg_extension", "SCHEMA").cast_values - else - super - end + exec_query("SELECT extname FROM pg_extension", "SCHEMA").cast_values end # Returns the configured supported identifier length supported by PostgreSQL - def table_alias_length - @max_identifier_length ||= select_value("SHOW max_identifier_length", "SCHEMA").to_i + def max_identifier_length + @max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i end - alias index_name_length table_alias_length + 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) clear_cache! - exec_query "SET SESSION AUTHORIZATION #{user}" + execute("SET SESSION AUTHORIZATION #{user}") end def use_insert_returning? @use_insert_returning end - def update_table_definition(table_name, base) #:nodoc: - PostgreSQL::Table.new(table_name, base) - end - - def lookup_cast_type(sql_type) # :nodoc: - oid = execute("SELECT #{quote(sql_type)}::regtype::oid", "SCHEMA").first["oid"].to_i - super(oid) - end - def column_name_for_operation(operation, node) # :nodoc: OPERATION_ALIASES.fetch(operation) { operation.downcase } end @@ -401,8 +402,7 @@ module ActiveRecord end private - - # See http://www.postgresql.org/docs/current/static/errcodes-appendix.html + # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" NUMERIC_VALUE_OUT_OF_RANGE = "22003" NOT_NULL_VIOLATION = "23502" @@ -410,11 +410,13 @@ module ActiveRecord UNIQUE_VIOLATION = "23505" SERIALIZATION_FAILURE = "40001" DEADLOCK_DETECTED = "40P01" + LOCK_NOT_AVAILABLE = "55P03" + QUERY_CANCELED = "57014" def translate_exception(exception, message) return exception unless exception.respond_to?(:result) - case exception.result.try(:error_field, PGresult::PG_DIAG_SQLSTATE) + case exception.result.try(:error_field, PG::PG_DIAG_SQLSTATE) when UNIQUE_VIOLATION RecordNotUnique.new(message) when FOREIGN_KEY_VIOLATION @@ -429,6 +431,10 @@ module ActiveRecord SerializationFailure.new(message) when DEADLOCK_DETECTED Deadlocked.new(message) + when LOCK_NOT_AVAILABLE + LockWaitTimeout.new(message) + when QUERY_CANCELED + QueryCanceled.new(message) else super end @@ -436,7 +442,7 @@ module ActiveRecord def get_oid_type(oid, fmod, column_name, sql_type = "".freeze) if !type_map.key?(oid) - load_additional_types(type_map, [oid]) + load_additional_types([oid]) end type_map.fetch(oid, fmod, sql_type) { @@ -447,10 +453,10 @@ module ActiveRecord } end - def initialize_type_map(m) - register_class_with_limit m, "int2", Type::Integer - register_class_with_limit m, "int4", Type::Integer - register_class_with_limit m, "int8", Type::Integer + def initialize_type_map(m = type_map) + m.register_type "int2", Type::Integer.new(limit: 2) + m.register_type "int4", Type::Integer.new(limit: 4) + m.register_type "int8", Type::Integer.new(limit: 8) m.register_type "oid", OID::Oid.new m.register_type "float4", Type::Float.new m.alias_type "float8", "float4" @@ -463,13 +469,13 @@ module ActiveRecord register_class_with_limit m, "bit", OID::Bit register_class_with_limit m, "varbit", OID::BitVarying m.alias_type "timestamptz", "timestamp" - m.register_type "date", Type::Date.new + m.register_type "date", OID::Date.new m.register_type "money", OID::Money.new m.register_type "bytea", OID::Bytea.new m.register_type "point", OID::Point.new m.register_type "hstore", OID::Hstore.new - m.register_type "json", OID::Json.new + m.register_type "json", Type::Json.new m.register_type "jsonb", OID::Jsonb.new m.register_type "cidr", OID::Cidr.new m.register_type "inet", OID::Inet.new @@ -514,18 +520,7 @@ module ActiveRecord end end - load_additional_types(m) - end - - def extract_limit(sql_type) - case sql_type - when /^bigint/i, /^int8/i - 8 - when /^smallint/i - 2 - else - super - end + load_additional_types end # Extracts the value from a PostgreSQL column default definition. @@ -560,10 +555,10 @@ module ActiveRecord end def has_default_function?(default_value, default) - !default_value && (%r{\w+\(.*\)|\(.*\)::\w+} === default) + !default_value && %r{\w+\(.*\)|\(.*\)::\w+|CURRENT_DATE|CURRENT_TIMESTAMP}.match?(default) end - def load_additional_types(type_map, oids = nil) + def load_additional_types(oids = nil) initializer = OID::TypeMapInitializer.new(type_map) if supports_ranges? @@ -582,7 +577,7 @@ module ActiveRecord if oids query += "WHERE t.oid::integer IN (%s)" % oids.join(", ") else - query += initializer.query_conditions_for_initial_load(type_map) + query += initializer.query_conditions_for_initial_load end execute_and_clear(query, "SCHEMA", []) do |records| @@ -606,6 +601,8 @@ module ActiveRecord end def exec_no_cache(sql, name, binds) + materialize_transactions + type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @@ -615,6 +612,8 @@ module ActiveRecord end def exec_cache(sql, name, binds) + materialize_transactions + stmt_key = prepare_statement(sql) type_casted_binds = type_casted_binds(binds) @@ -647,11 +646,11 @@ module ActiveRecord # ActiveRecord::PreparedStatementCacheExpired # # Check here for more details: - # http://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/utils/cache/plancache.c#l573 + # https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/utils/cache/plancache.c#l573 CACHED_PLAN_HEURISTIC = "cached plan must not change result type".freeze def is_cached_plan_failure?(e) pgerror = e.cause - code = pgerror.result.result_error_field(PGresult::PG_DIAG_SQLSTATE) + code = pgerror.result.result_error_field(PG::PG_DIAG_SQLSTATE) code == FEATURE_NOT_SUPPORTED && pgerror.message.include?(CACHED_PLAN_HEURISTIC) rescue false @@ -690,7 +689,7 @@ module ActiveRecord # Connects to a PostgreSQL server and sets up the adapter depending on the # connected server's characteristics. def connect - @connection = PGconn.connect(@connection_parameters) + @connection = PG.connect(@connection_parameters) configure_connection rescue ::PG::Error => error if error.message.include?("does not exist") @@ -712,18 +711,20 @@ module ActiveRecord # Use standard-conforming strings so we don't have to do the E'...' dance. set_standard_conforming_strings + variables = @config.fetch(:variables, {}).stringify_keys + # If using Active Record's time zone support configure the connection to return # TIMESTAMP WITH ZONE types in UTC. - # (SET TIME ZONE does not use an equals sign like other SET variables) - if ActiveRecord::Base.default_timezone == :utc - execute("SET time zone 'UTC'", "SCHEMA") - elsif @local_tz - execute("SET time zone '#{@local_tz}'", "SCHEMA") + unless variables["timezone"] + if ActiveRecord::Base.default_timezone == :utc + variables["timezone"] = "UTC" + elsif @local_tz + variables["timezone"] = @local_tz + end end # SET statements from :variables config hash - # http://www.postgresql.org/docs/current/static/sql-set.html - variables = @config[:variables] || {} + # https://www.postgresql.org/docs/current/static/sql-set.html variables.map do |k, v| if v == ":default" || v == :default # Sets the value to the global or compile default @@ -734,11 +735,6 @@ module ActiveRecord end end - # Returns the current ID of a table's sequence. - def last_insert_id_result(sequence_name) - exec_query("SELECT currval('#{sequence_name}')", "SQL") - end - # Returns the list of a table's column names, data types, and default values. # # The underlying query is roughly: @@ -777,8 +773,8 @@ module ActiveRecord $1.strip if $1 end - def create_table_definition(*args) - PostgreSQL::TableDefinition.new(*args) + def arel_visitor + Arel::Visitors::PostgreSQL.new(self) end def can_perform_case_insensitive_comparison_for?(column) @@ -849,12 +845,12 @@ module ActiveRecord ActiveRecord::Type.register(:bit_varying, OID::BitVarying, adapter: :postgresql) ActiveRecord::Type.register(:binary, OID::Bytea, adapter: :postgresql) ActiveRecord::Type.register(:cidr, OID::Cidr, adapter: :postgresql) + ActiveRecord::Type.register(:date, OID::Date, adapter: :postgresql) ActiveRecord::Type.register(:datetime, OID::DateTime, adapter: :postgresql) ActiveRecord::Type.register(:decimal, OID::Decimal, adapter: :postgresql) ActiveRecord::Type.register(:enum, OID::Enum, adapter: :postgresql) ActiveRecord::Type.register(:hstore, OID::Hstore, adapter: :postgresql) ActiveRecord::Type.register(:inet, OID::Inet, adapter: :postgresql) - ActiveRecord::Type.register(:json, OID::Json, adapter: :postgresql) ActiveRecord::Type.register(:jsonb, OID::Jsonb, adapter: :postgresql) ActiveRecord::Type.register(:money, OID::Money, adapter: :postgresql) ActiveRecord::Type.register(:point, OID::Point, adapter: :postgresql) diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index 4d339b0a8c..c29cf1f9a1 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters class SchemaCache @@ -26,7 +28,7 @@ module ActiveRecord coder["columns_hash"] = @columns_hash coder["primary_keys"] = @primary_keys coder["data_sources"] = @data_sources - coder["version"] = ActiveRecord::Migrator.current_version + coder["version"] = connection.migration_context.current_version end def init_with(coder) @@ -98,7 +100,7 @@ module ActiveRecord def marshal_dump # if we get current version during initialization, it happens stack over flow. - @version = ActiveRecord::Migrator.current_version + @version = connection.migration_context.current_version [@version, @columns, @columns_hash, @primary_keys, @data_sources] 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 9e12ae0de8..8489bcbf1d 100644 --- a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # :stopdoc: module ConnectionAdapters diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/explain_pretty_printer.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/explain_pretty_printer.rb index 6fe3e1211e..832fdfe5c4 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/explain_pretty_printer.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/explain_pretty_printer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module SQLite3 diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb index 7276a65098..abedf01f10 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module SQLite3 @@ -15,13 +17,30 @@ module ActiveRecord end def quoted_time(value) - quoted_date(value) + value = value.change(year: 2000, month: 1, day: 1) + quoted_date(value).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") end def quoted_binary(value) "x'#{value.hex}'" end + def quoted_true + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? "1".freeze : "'t'".freeze + end + + def unquoted_true + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? 1 : "t".freeze + end + + def quoted_false + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? "0".freeze : "'f'".freeze + end + + def unquoted_false + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? 0 : "f".freeze + end + private def _type_cast(value) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb index bc798d1dbb..b842561317 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module SQLite3 diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb index e157e4b218..c9855019c1 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb @@ -1,27 +1,18 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module SQLite3 - module ColumnMethods - def primary_key(name, type = :primary_key, **options) - if %i(integer bigint).include?(type) && (options.delete(:auto_increment) == true || !options.key?(:default)) - type = :primary_key - end - - super - end - end - class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition - include ColumnMethods - def references(*args, **options) super(*args, type: :integer, **options) end alias :belongs_to :references - end - class Table < ActiveRecord::ConnectionAdapters::Table - include ColumnMethods + private + def integer_like_primary_key_type(type, options) + :primary_key + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb index eec018eda3..621678ec65 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb @@ -1,9 +1,10 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters module SQLite3 - module ColumnDumper # :nodoc: + class SchemaDumper < ConnectionAdapters::SchemaDumper # :nodoc: private - def default_primary_key?(column) schema_type(column) == :integer end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb new file mode 100644 index 0000000000..24e7bc65fa --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module SchemaStatements # :nodoc: + # Returns an array of indexes for the given table. + def indexes(table_name) + exec_query("PRAGMA index_list(#{quote_table_name(table_name)})", "SCHEMA").map do |row| + # Indexes SQLite creates implicitly for internal use start with "sqlite_". + # See https://www.sqlite.org/fileformat2.html#intschema + next if row["name"].starts_with?("sqlite_") + + index_sql = query_value(<<-SQL, "SCHEMA") + SELECT sql + FROM sqlite_master + WHERE name = #{quote(row['name'])} AND type = 'index' + UNION ALL + SELECT sql + FROM sqlite_temp_master + WHERE name = #{quote(row['name'])} AND type = 'index' + SQL + + /\sWHERE\s+(?<where>.+)$/i =~ index_sql + + columns = exec_query("PRAGMA index_info(#{quote(row['name'])})", "SCHEMA").map do |col| + col["name"] + end + + # Add info on sort order for columns (only desc order is explicitly specified, asc is + # the default) + orders = {} + if index_sql # index_sql can be null in case of primary key indexes + index_sql.scan(/"(\w+)" DESC/).flatten.each { |order_column| + orders[order_column] = :desc + } + end + + IndexDefinition.new( + table_name, + row["name"], + row["unique"] != 0, + columns, + where: where, + orders: orders + ) + end.compact + end + + def create_schema_dumper(options) + SQLite3::SchemaDumper.create(self, options) + end + + private + def schema_creation + SQLite3::SchemaCreation.new(self) + end + + def create_table_definition(*args) + SQLite3::TableDefinition.new(*args) + end + + def new_column_from_field(table_name, field) + default = \ + case field["dflt_value"] + when /^null$/i + nil + when /^'(.*)'$/m + $1.gsub("''", "'") + when /^"(.*)"$/m + $1.gsub('""', '"') + else + field["dflt_value"] + 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"]) + end + + def data_source_sql(name = nil, type: nil) + scope = quoted_scope(name, type: type) + scope[:type] ||= "'table','view'" + + sql = "SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'".dup + sql << " AND name = #{scope[:name]}" if scope[:name] + sql << " AND type IN (#{scope[:type]})" + sql + end + + def quoted_scope(name = nil, type: nil) + type = \ + case type + when "BASE TABLE" + "'table'" + when "VIEW" + "'view'" + end + scope = {} + scope[:name] = quote(name) if name + scope[:type] = type if type + scope + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 285b0ec243..efe454fa7f 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/sqlite3/explain_pretty_printer" @@ -5,6 +7,7 @@ require "active_record/connection_adapters/sqlite3/quoting" require "active_record/connection_adapters/sqlite3/schema_creation" require "active_record/connection_adapters/sqlite3/schema_definitions" require "active_record/connection_adapters/sqlite3/schema_dumper" +require "active_record/connection_adapters/sqlite3/schema_statements" gem "sqlite3", "~> 1.3.6" require "sqlite3" @@ -12,6 +15,8 @@ require "sqlite3" module ActiveRecord module ConnectionHandling # :nodoc: def sqlite3_connection(config) + config = config.symbolize_keys + # Require database. unless config[:database] raise ArgumentError, "No database file specified. Missing argument: database" @@ -28,7 +33,7 @@ module ActiveRecord db = SQLite3::Database.new( config[:database].to_s, - results_as_hash: true + config.merge(results_as_hash: true) ) db.busy_timeout(ConnectionAdapters::SQLite3Adapter.type_cast_config_to_integer(config[:timeout])) if config[:timeout] @@ -54,10 +59,10 @@ module ActiveRecord ADAPTER_NAME = "SQLite".freeze include SQLite3::Quoting - include SQLite3::ColumnDumper + include SQLite3::SchemaStatements NATIVE_DATABASE_TYPES = { - primary_key: "INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL", + primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL", string: { name: "varchar" }, text: { name: "text" }, integer: { name: "integer" }, @@ -67,35 +72,44 @@ module ActiveRecord time: { name: "time" }, date: { name: "date" }, binary: { name: "blob" }, - boolean: { name: "boolean" } + boolean: { name: "boolean" }, + json: { name: "json" }, } - class StatementPool < ConnectionAdapters::StatementPool - private + ## + # :singleton-method: + # Indicates whether boolean values are stored in sqlite3 databases as 1 + # and 0 or 't' and 'f'. Leaving <tt>ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer</tt> + # set to false is deprecated. SQLite databases have used 't' and 'f' to + # serialize boolean values and must have old data converted to 1 and 0 + # (its native boolean serialization) before setting this flag to true. + # Conversion can be accomplished by setting up a rake task which runs + # + # ExampleModel.where("boolean_column = 't'").update_all(boolean_column: 1) + # ExampleModel.where("boolean_column = 'f'").update_all(boolean_column: 0) + # for all models and all boolean columns, after which the flag must be set + # to true by adding the following to your <tt>application.rb</tt> file: + # + # Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true + class_attribute :represent_boolean_as_integer, default: false + class StatementPool < ConnectionAdapters::StatementPool # :nodoc: + private def dealloc(stmt) - stmt[:stmt].close unless stmt[:stmt].closed? + stmt.close unless stmt.closed? end end - def update_table_definition(table_name, base) # :nodoc: - SQLite3::Table.new(table_name, base) - end - - def schema_creation # :nodoc: - SQLite3::SchemaCreation.new self - end - - def arel_visitor # :nodoc: - Arel::Visitors::SQLite.new(self) - end - def initialize(connection, logger, connection_options, config) super(connection, logger, config) - @active = nil + @active = true @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) + if sqlite_version < "3.8.0" + raise "Your version of SQLite (#{sqlite_version}) is too old. Active Record supports SQLite >= 3.8." + end + configure_connection end @@ -108,12 +122,6 @@ module ActiveRecord end def supports_partial_index? - sqlite_version >= "3.8.0" - end - - # Returns true, since this connection adapter supports prepared statement - # caching. - def supports_statement_cache? true end @@ -122,7 +130,7 @@ module ActiveRecord end def supports_foreign_keys_in_create? - sqlite_version >= "3.6.19" + true end def supports_views? @@ -133,12 +141,12 @@ module ActiveRecord true end - def supports_multi_insert? - sqlite_version >= "3.7.11" + def supports_json? + true end def active? - @active != false + @active end # Disconnects from the database if already connected. Otherwise, this @@ -158,10 +166,6 @@ module ActiveRecord true end - def valid_type?(type) # :nodoc: - true - end - # Returns 62. SQLite supports index names up to 64 # characters. The rest is used by Rails internally to perform # temporary rename operations @@ -182,16 +186,23 @@ module ActiveRecord true end + def supports_lazy_transactions? + true + end + # REFERENTIAL INTEGRITY ==================================== def disable_referential_integrity # :nodoc: - old = select_value("PRAGMA foreign_keys") + old_foreign_keys = query_value("PRAGMA foreign_keys") + old_defer_foreign_keys = query_value("PRAGMA defer_foreign_keys") begin + execute("PRAGMA defer_foreign_keys = ON") execute("PRAGMA foreign_keys = OFF") yield ensure - execute("PRAGMA foreign_keys = #{old}") + execute("PRAGMA defer_foreign_keys = #{old_defer_foreign_keys}") + execute("PRAGMA foreign_keys = #{old_foreign_keys}") end end @@ -205,6 +216,8 @@ module ActiveRecord end def exec_query(sql, name = nil, binds = [], prepare: false) + materialize_transactions + type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds) do @@ -222,11 +235,8 @@ module ActiveRecord stmt.close end else - cache = @statements[sql] ||= { - stmt: @connection.prepare(sql) - } - stmt = cache[:stmt] - cols = cache[:cols] ||= stmt.columns + stmt = @statements[sql] ||= @connection.prepare(sql) + cols = stmt.columns stmt.reset! stmt.bind_params(type_casted_binds) records = stmt.to_a @@ -248,6 +258,8 @@ module ActiveRecord end def execute(sql, name = nil) #:nodoc: + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @connection.execute(sql) @@ -269,92 +281,6 @@ module ActiveRecord # SCHEMA STATEMENTS ======================================== - def tables # :nodoc: - select_values("SELECT name FROM sqlite_master WHERE type = 'table' AND name <> 'sqlite_sequence'", "SCHEMA") - end - - def data_sources # :nodoc: - select_values("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name <> 'sqlite_sequence'", "SCHEMA") - end - - def views # :nodoc: - select_values("SELECT name FROM sqlite_master WHERE type = 'view' AND name <> 'sqlite_sequence'", "SCHEMA") - end - - def table_exists?(table_name) # :nodoc: - return false unless table_name.present? - - sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name <> 'sqlite_sequence'" - sql << " AND name = #{quote(table_name)}" - - select_values(sql, "SCHEMA").any? - end - - def data_source_exists?(table_name) # :nodoc: - return false unless table_name.present? - - sql = "SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name <> 'sqlite_sequence'" - sql << " AND name = #{quote(table_name)}" - - select_values(sql, "SCHEMA").any? - end - - def view_exists?(view_name) # :nodoc: - return false unless view_name.present? - - sql = "SELECT name FROM sqlite_master WHERE type = 'view' AND name <> 'sqlite_sequence'" - sql << " AND name = #{quote(view_name)}" - - select_values(sql, "SCHEMA").any? - end - - def new_column_from_field(table_name, field) # :nondoc: - case field["dflt_value"] - when /^null$/i - field["dflt_value"] = nil - when /^'(.*)'$/m - field["dflt_value"] = $1.gsub("''", "'") - when /^"(.*)"$/m - field["dflt_value"] = $1.gsub('""', '"') - end - - collation = field["collation"] - sql_type = field["type"] - type_metadata = fetch_type_metadata(sql_type) - new_column(field["name"], field["dflt_value"], type_metadata, field["notnull"].to_i == 0, table_name, nil, collation) - end - - # Returns an array of indexes for the given table. - def indexes(table_name, name = nil) #:nodoc: - if name - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Passing name to #indexes is deprecated without replacement. - MSG - end - - exec_query("PRAGMA index_list(#{quote_table_name(table_name)})", "SCHEMA").map do |row| - sql = <<-SQL - SELECT sql - FROM sqlite_master - WHERE name=#{quote(row['name'])} AND type='index' - UNION ALL - SELECT sql - FROM sqlite_temp_master - WHERE name=#{quote(row['name'])} AND type='index' - SQL - index_sql = exec_query(sql).first["sql"] - match = /\sWHERE\s+(.+)$/i.match(index_sql) - where = match[1] if match - IndexDefinition.new( - table_name, - row["name"], - row["unique"] != 0, - exec_query("PRAGMA index_info('#{row['name']}')", "SCHEMA").map { |col| - col["name"] - }, nil, nil, where) - end - end - def primary_keys(table_name) # :nodoc: pks = table_structure(table_name).select { |f| f["pk"] > 0 } pks.sort_by { |f| f["pk"] }.map { |f| f["name"] } @@ -374,19 +300,18 @@ module ActiveRecord rename_table_indexes(table_name, new_name) end - # See: http://www.sqlite.org/lang_altertable.html - # SQLite has an additional restriction on the ALTER TABLE statement - def valid_alter_table_type?(type) - type.to_sym != :primary_key + def valid_alter_table_type?(type, options = {}) + !invalid_alter_table_type?(type, options) end + deprecate :valid_alter_table_type? def add_column(table_name, column_name, type, options = {}) #:nodoc: - if valid_alter_table_type?(type) - super(table_name, column_name, type, options) - else + if invalid_alter_table_type?(type, options) alter_table(table_name) do |definition| definition.column(column_name, type, options) end + else + super end end @@ -439,7 +364,7 @@ module ActiveRecord alias :add_belongs_to :add_reference def foreign_keys(table_name) - fk_info = select_all("PRAGMA foreign_key_list(#{quote(table_name)})", "SCHEMA") + fk_info = exec_query("PRAGMA foreign_key_list(#{quote(table_name)})", "SCHEMA") fk_info.map do |row| options = { column: row["from"], @@ -451,7 +376,31 @@ module ActiveRecord end end + def insert_fixtures(rows, table_name) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `insert_fixtures` is deprecated and will be removed in the next version of Rails. + Consider using `insert_fixtures_set` for performance improvement. + MSG + insert_fixtures_set(table_name => rows) + end + + def insert_fixtures_set(fixture_set, tables_to_delete = []) + disable_referential_integrity do + transaction(requires_new: true) do + tables_to_delete.each { |table| delete "DELETE FROM #{quote_table_name(table)}", "Fixture Delete" } + + fixture_set.each do |table_name, rows| + rows.each { |row| insert_fixture(row, table_name) } + end + end + end + end + private + def initialize_type_map(m = type_map) + super + register_class_with_limit m, %r(int)i, SQLite3Integer + end def table_structure(table_name) structure = exec_query("PRAGMA table_info(#{quote_table_name(table_name)})", "SCHEMA") @@ -460,14 +409,33 @@ module ActiveRecord end alias column_definitions table_structure + # See: https://www.sqlite.org/lang_altertable.html + # SQLite has an additional restriction on the ALTER TABLE statement + def invalid_alter_table_type?(type, options) + type.to_sym == :primary_key || options[:primary_key] + end + def alter_table(table_name, options = {}) altered_table_name = "a#{table_name}" - caller = lambda { |definition| yield definition if block_given? } + foreign_keys = foreign_keys(table_name) + + caller = lambda do |definition| + rename = options[:rename] || {} + foreign_keys.each do |fk| + if column = rename[fk.options[:column]] + fk.options[:column] = column + end + definition.foreign_key(fk.to_table, fk.options) + end + + yield definition if block_given? + end transaction do - move_table(table_name, altered_table_name, - options.merge(temporary: true)) - move_table(altered_table_name, table_name, &caller) + disable_referential_integrity do + move_table(table_name, altered_table_name, options.merge(temporary: true)) + move_table(altered_table_name, table_name, &caller) + end end end @@ -481,19 +449,23 @@ module ActiveRecord options[:id] = false create_table(to, options) do |definition| @definition = definition - @definition.primary_key(from_primary_key) if from_primary_key.present? + if from_primary_key.is_a?(Array) + @definition.primary_keys from_primary_key + end columns(from).each do |column| column_name = options[:rename] ? (options[:rename][column.name] || options[:rename][column.name.to_sym] || column.name) : column.name - next if column_name == from_primary_key @definition.column(column_name, column.type, limit: column.limit, default: column.default, precision: column.precision, scale: column.scale, - null: column.null, collation: column.collation) + null: column.null, collation: column.collation, + primary_key: column_name == from_primary_key + ) end + yield @definition if block_given? end copy_table_indexes(from, to, options[:rename] || {}) @@ -520,6 +492,7 @@ module ActiveRecord # index name can't be the same opts = { name: name.gsub(/(^|_)(#{from})_/, "\\1#{to}_"), internal: true } opts[:unique] = true if index.unique + opts[:where] = index.where if index.where add_index(to, columns, opts) end end @@ -539,7 +512,7 @@ module ActiveRecord end def sqlite_version - @sqlite_version ||= SQLite3Adapter::Version.new(select_value("select sqlite_version(*)")) + @sqlite_version ||= SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) end def translate_exception(exception, message) @@ -600,21 +573,25 @@ module ActiveRecord end end - def create_table_definition(*args) - SQLite3::TableDefinition.new(*args) - end - - def extract_foreign_key_action(specifier) - case specifier - when "CASCADE"; :cascade - when "SET NULL"; :nullify - when "RESTRICT"; :restrict - end + def arel_visitor + Arel::Visitors::SQLite.new(self) end def configure_connection execute("PRAGMA foreign_keys = ON", "SCHEMA") end + + class SQLite3Integer < Type::Integer # :nodoc: + private + def _limit + # INTEGER storage class can be stored 8 bytes value. + # See https://www.sqlite.org/datatype3.html#storage_classes_and_datatypes + limit || 8 + end + end + + ActiveRecord::Type.register(:integer, SQLite3Integer, adapter: :sqlite3) end + ActiveSupport.run_load_hooks(:active_record_sqlite3adapter, SQLite3Adapter) end end diff --git a/activerecord/lib/active_record/connection_adapters/statement_pool.rb b/activerecord/lib/active_record/connection_adapters/statement_pool.rb index 790db56185..46bd831da7 100644 --- a/activerecord/lib/active_record/connection_adapters/statement_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/statement_pool.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionAdapters class StatementPool # :nodoc: diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index 2ede92feff..18114f9e1c 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionHandling - RAILS_ENV = -> { (Rails.env if defined?(Rails.env)) || ENV["RAILS_ENV"] || ENV["RACK_ENV"] } + RAILS_ENV = -> { (Rails.env if defined?(Rails.env)) || ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence } DEFAULT_ENV = -> { RAILS_ENV.call || "default_env" } # Establishes the connection to the database. Accepts a hash as input where @@ -44,41 +46,18 @@ module ActiveRecord # # The exceptions AdapterNotSpecified, AdapterNotFound and +ArgumentError+ # may be returned on an error. - def establish_connection(config = nil) + def establish_connection(config_or_env = nil) raise "Anonymous class is not allowed." unless name - config ||= DEFAULT_ENV.call.to_sym - spec_name = self == Base ? "primary" : name - self.connection_specification_name = spec_name + config_or_env ||= DEFAULT_ENV.call.to_sym + pool_name = self == Base ? "primary" : name + self.connection_specification_name = pool_name resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(Base.configurations) - spec = resolver.resolve(config).symbolize_keys - spec[:name] = spec_name - - connection_handler.establish_connection(spec) - end - - class MergeAndResolveDefaultUrlConfig # :nodoc: - def initialize(raw_configurations) - @raw_config = raw_configurations.dup - @env = DEFAULT_ENV.call.to_s - end - - # Returns fully resolved connection hashes. - # Merges connection information from `ENV['DATABASE_URL']` if available. - def resolve - ConnectionAdapters::ConnectionSpecification::Resolver.new(config).resolve_all - end + config_hash = resolver.resolve(config_or_env, pool_name).symbolize_keys + config_hash[:name] = pool_name - private - def config - @raw_config.dup.tap do |cfg| - if url = ENV["DATABASE_URL"] - cfg[@env] ||= {} - cfg[@env]["url"] ||= url - end - end - end + connection_handler.establish_connection(config_hash) end # Returns the connection currently associated with the class. This can @@ -138,6 +117,6 @@ module ActiveRecord end delegate :clear_active_connections!, :clear_reloadable_connections!, - :clear_all_connections!, to: :connection_handler + :clear_all_connections!, :flush_idle_connections!, to: :connection_handler end end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index 0028dc0edb..82cf7563a2 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -1,12 +1,15 @@ -require "thread" +# frozen_string_literal: true + require "active_support/core_ext/hash/indifferent_access" -require "active_support/core_ext/object/duplicable" require "active_support/core_ext/string/filters" +require "concurrent/map" module ActiveRecord module Core extend ActiveSupport::Concern + FILTERED = "[FILTERED]" # :nodoc: + included do ## # :singleton-method: @@ -17,8 +20,15 @@ module ActiveRecord mattr_accessor :logger, instance_writer: false ## + # :singleton-method: + # + # Specifies if the methods calling database queries should be logged below + # their relevant queries. Defaults to false. + mattr_accessor :verbose_query_logs, instance_writer: false, default: false + + ## # Contains the database configuration - as is typically stored in config/database.yml - - # as a Hash. + # as an ActiveRecord::DatabaseConfigurations object. # # For example, the following database.yml... # @@ -32,22 +42,18 @@ module ActiveRecord # # ...would result in ActiveRecord::Base.configurations to look like this: # - # { - # 'development' => { - # 'adapter' => 'sqlite3', - # 'database' => 'db/development.sqlite3' - # }, - # 'production' => { - # 'adapter' => 'sqlite3', - # 'database' => 'db/production.sqlite3' - # } - # } + # #<ActiveRecord::DatabaseConfigurations:0x00007fd1acbdf800 @configurations=[ + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 @env_name="development", + # @spec_name="primary", @config={"adapter"=>"sqlite3", "database"=>"db/development.sqlite3"}>, + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbdea90 @env_name="production", + # @spec_name="primary", @config={"adapter"=>"mysql2", "database"=>"db/production.sqlite3"}> + # ]> def self.configurations=(config) - @@configurations = ActiveRecord::ConnectionHandling::MergeAndResolveDefaultUrlConfig.new(config).resolve + @@configurations = ActiveRecord::DatabaseConfigurations.new(config) end self.configurations = {} - # Returns fully resolved configurations hash + # Returns fully resolved ActiveRecord::DatabaseConfigurations object def self.configurations @@configurations end @@ -56,8 +62,7 @@ module ActiveRecord # :singleton-method: # Determines whether to use Time.utc (using :utc) or Time.local (using :local) when pulling # dates and times from the database. This is set to :utc by default. - mattr_accessor :default_timezone, instance_writer: false - self.default_timezone = :utc + mattr_accessor :default_timezone, instance_writer: false, default: :utc ## # :singleton-method: @@ -67,51 +72,35 @@ module ActiveRecord # ActiveRecord::Schema file which can be loaded into any database that # supports migrations. Use :ruby if you want to have different database # adapters for, e.g., your development and test environments. - mattr_accessor :schema_format, instance_writer: false - self.schema_format = :ruby + mattr_accessor :schema_format, instance_writer: false, default: :ruby ## # :singleton-method: # Specifies if an error should be raised if the query has an order being # ignored when doing batch queries. Useful in applications where the # scope being ignored is error-worthy, rather than a warning. - mattr_accessor :error_on_ignored_order, instance_writer: false - self.error_on_ignored_order = false - - def self.error_on_ignored_order_or_limit - ActiveSupport::Deprecation.warn(<<-MSG.squish) - The flag error_on_ignored_order_or_limit is deprecated. Limits are - now supported. Please use error_on_ignored_order instead. - MSG - error_on_ignored_order - end + mattr_accessor :error_on_ignored_order, instance_writer: false, default: false - def error_on_ignored_order_or_limit - self.class.error_on_ignored_order_or_limit - end - - def self.error_on_ignored_order_or_limit=(value) - ActiveSupport::Deprecation.warn(<<-MSG.squish) - The flag error_on_ignored_order_or_limit is deprecated. Limits are - now supported. Please use error_on_ignored_order= instead. - MSG - self.error_on_ignored_order = value - end + # :singleton-method: + # Specify the behavior for unsafe raw query methods. Values are as follows + # deprecated - Warnings are logged when unsafe raw SQL is passed to + # query methods. + # disabled - Unsafe raw SQL passed to query methods results in + # UnknownAttributeReference exception. + mattr_accessor :allow_unsafe_raw_sql, instance_writer: false, default: :deprecated ## # :singleton-method: # Specify whether or not to use timestamps for migration versions - mattr_accessor :timestamped_migrations, instance_writer: false - self.timestamped_migrations = true + mattr_accessor :timestamped_migrations, instance_writer: false, default: true ## # :singleton-method: # Specify whether schema dump should happen at the end of the - # db:migrate rake task. This is true by default, which is useful for the + # db:migrate rails command. This is true by default, which is useful for the # development environment. This should ideally be false in the production # environment where dumping schema is rarely needed. - mattr_accessor :dump_schema_after_migration, instance_writer: false - self.dump_schema_after_migration = true + mattr_accessor :dump_schema_after_migration, instance_writer: false, default: true ## # :singleton-method: @@ -120,8 +109,7 @@ module ActiveRecord # schema_search_path are dumped. Use :all to dump all schemas regardless # of schema_search_path, or a string of comma separated schemas for a # custom list. - mattr_accessor :dump_schemas, instance_writer: false - self.dump_schemas = :schema_search_path + mattr_accessor :dump_schemas, instance_writer: false, default: :schema_search_path ## # :singleton-method: @@ -130,7 +118,6 @@ module ActiveRecord # be used to identify queries which load thousands of records and # potentially cause memory bloat. mattr_accessor :warn_on_records_fetched_greater_than, instance_writer: false - self.warn_on_records_fetched_greater_than = nil mattr_accessor :maintain_test_schema, instance_accessor: false @@ -138,6 +125,10 @@ module ActiveRecord class_attribute :default_connection_handler, instance_writer: false + ## + # Specifies columns which don't want to be exposed while calling #inspect + class_attribute :filter_attributes, instance_writer: false, default: [] + def self.connection_handler ActiveRecord::RuntimeRegistry.connection_handler || default_connection_handler end @@ -149,14 +140,9 @@ module ActiveRecord self.default_connection_handler = ConnectionAdapters::ConnectionHandler.new end - module ClassMethods - def allocate - define_attribute_methods - super - end - + module ClassMethods # :nodoc: def initialize_find_by_cache # :nodoc: - @find_by_statement_cache = { true => {}.extend(Mutex_m), false => {}.extend(Mutex_m) } + @find_by_statement_cache = { true => Concurrent::Map.new, false => Concurrent::Map.new } end def inherited(child_class) # :nodoc: @@ -175,8 +161,7 @@ module ActiveRecord id = ids.first - return super if id.kind_of?(Array) || - id.is_a?(ActiveRecord::Base) + return super if StatementCache.unsupported_value?(id) key = primary_key @@ -184,7 +169,7 @@ module ActiveRecord where(key => params.bind).limit(1) } - record = statement.execute([id], self, connection).first + record = statement.execute([id], connection).first unless record raise RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id}", name, primary_key, id) @@ -201,7 +186,7 @@ module ActiveRecord hash = args.first return super if !(Hash === hash) || hash.values.any? { |v| - v.nil? || Array === v || Hash === v || Relation === v || Base === v + StatementCache.unsupported_value?(v) } # We can't cache Post.find_by(author: david) ...yet @@ -216,7 +201,7 @@ module ActiveRecord where(wheres).limit(1) } begin - statement.execute(hash.values, self, connection).first + statement.execute(hash.values, connection).first rescue TypeError raise ActiveRecord::StatementInvalid rescue ::RangeError @@ -258,7 +243,7 @@ module ActiveRecord end end - # Overwrite the default class equality method to provide support for association proxies. + # Overwrite the default class equality method to provide support for decorated models. def ===(object) object.is_a?(self) end @@ -272,16 +257,6 @@ module ActiveRecord @arel_table ||= Arel::Table.new(table_name, type_caster: type_caster) end - # Returns the Arel engine. - def arel_engine # :nodoc: - @arel_engine ||= - if Base == self || connection_handler.retrieve_connection_pool(connection_specification_name) - self - else - superclass.arel_engine - end - end - def arel_attribute(name, table = arel_table) # :nodoc: name = attribute_alias(name) if attribute_alias?(name) table[name] @@ -299,16 +274,15 @@ module ActiveRecord def cached_find_by_statement(key, &block) cache = @find_by_statement_cache[connection.prepared_statements] - cache[key] || cache.synchronize { - cache[key] ||= StatementCache.create(connection, &block) - } + cache.compute_if_absent(key) { StatementCache.create(connection, &block) } end def relation - relation = Relation.create(self, arel_table, predicate_builder) + relation = Relation.create(self) if finder_needs_type_condition? && !ignore_default_scope? - relation.where(type_condition).create_with(inheritance_column.to_s => sti_name) + relation.where!(type_condition) + relation.create_with!(inheritance_column.to_s => sti_name) else relation end @@ -373,6 +347,28 @@ module ActiveRecord end ## + # Initializer used for instantiating objects that have been read from the + # database. +attributes+ should be an attributes object, and unlike the + # `initialize` method, no assignment calls are made per attribute. + # + # :nodoc: + def init_from_db(attributes) + init_internals + + @new_record = false + @attributes = attributes + + self.class.define_attribute_methods + + yield self if block_given? + + _run_find_callbacks + _run_initialize_callbacks + + self + end + + ## # :method: clone # Identical to Ruby's clone method. This is a "shallow" copy. Be warned that your attributes are not copied. # That means that modifying attributes of the clone will modify the original, since they will both point to the @@ -405,8 +401,10 @@ module ActiveRecord _run_initialize_callbacks - @new_record = true - @destroyed = false + @new_record = true + @destroyed = false + @_start_transaction_state = {} + @transaction_state = nil super end @@ -495,12 +493,17 @@ module ActiveRecord # Returns the contents of the record as a nicely formatted string. def inspect + filter_attributes = self.filter_attributes.map(&:to_s).to_set # We check defined?(@attributes) not to issue warnings if the object is # allocated but not initialized. inspection = if defined?(@attributes) && @attributes self.class.attribute_names.collect do |name| if has_attribute?(name) - "#{name}: #{attribute_for_inspect(name)}" + if filter_attributes.include?(name) && !read_attribute(name).nil? + "#{name}: #{ActiveRecord::Core::FILTERED}" + else + "#{name}: #{attribute_for_inspect(name)}" + end end end.compact.join(", ") else @@ -514,6 +517,7 @@ module ActiveRecord # when pp is required. def pretty_print(pp) return super if custom_inspect_method_defined? + filter_attributes = self.filter_attributes.map(&:to_s).to_set pp.object_address_group(self) do if defined?(@attributes) && @attributes column_names = self.class.column_names.select { |name| has_attribute?(name) || new_record? } @@ -524,7 +528,11 @@ module ActiveRecord pp.text column_name pp.text ":" pp.breakable - pp.pp column_value + if filter_attributes.include?(column_name) && !column_value.nil? + pp.text ActiveRecord::Core::FILTERED + else + pp.pp column_value + end end end else @@ -548,7 +556,7 @@ module ActiveRecord # # So we can avoid the +method_missing+ hit by explicitly defining +#to_ary+ as +nil+ here. # - # See also http://tenderlovemaking.com/2011/06/28/til-its-ok-to-return-nil-from-to_ary.html + # See also https://tenderlovemaking.com/2011/06/28/til-its-ok-to-return-nil-from-to_ary.html def to_ary nil end @@ -559,7 +567,6 @@ module ActiveRecord @marked_for_destruction = false @destroyed_by_association = nil @new_record = true - @txn = nil @_start_transaction_state = {} @transaction_state = nil end diff --git a/activerecord/lib/active_record/counter_cache.rb b/activerecord/lib/active_record/counter_cache.rb index cbd71a3779..aad30b40ea 100644 --- a/activerecord/lib/active_record/counter_cache.rb +++ b/activerecord/lib/active_record/counter_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record Counter Cache module CounterCache @@ -45,13 +47,17 @@ module ActiveRecord reflection = child_class._reflections.values.find { |e| e.belongs_to? && e.foreign_key.to_s == foreign_key && e.options[:counter_cache].present? } counter_name = reflection.counter_cache_column - updates = { counter_name.to_sym => object.send(counter_association).count(:all) } - updates.merge!(touch_updates(touch)) if touch + updates = { counter_name => object.send(counter_association).count(:all) } + + if touch + names = touch if touch != true + updates.merge!(touch_attributes_with_time(*names)) + end unscoped.where(primary_key => object.id).update_all(updates) end - return true + true end # A generic "counter updater" implementation, intended primarily to be @@ -66,8 +72,8 @@ module ActiveRecord # * +counters+ - A Hash containing the names of the fields # to update as keys and the amount to update the field by as values. # * <tt>:touch</tt> option - Touch timestamp columns when updating. - # Pass +true+ to touch +updated_at+ and/or +updated_on+. Pass a symbol to - # touch that column or an array of symbols to touch just those ones. + # If attribute names are passed, they are updated along with updated_at/on + # attributes. # # ==== Examples # @@ -96,20 +102,7 @@ module ActiveRecord # # `updated_at` = '2016-10-13T09:59:23-05:00' # # WHERE id IN (10, 15) def update_counters(id, counters) - touch = counters.delete(:touch) - - updates = counters.map do |counter_name, value| - operator = value < 0 ? "-" : "+" - quoted_column = connection.quote_column_name(counter_name) - "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{value.abs}" - end - - if touch - touch_updates = touch_updates(touch) - updates << sanitize_sql_for_assignment(touch_updates) unless touch_updates.empty? - end - - unscoped.where(primary_key => id).update_all updates.join(", ") + unscoped.where!(primary_key => id).update_counters(counters) end # Increment a numeric field by one, via a direct SQL update. @@ -163,24 +156,15 @@ module ActiveRecord def decrement_counter(counter_name, id, touch: nil) update_counters(id, counter_name => -1, touch: touch) end - - private - def touch_updates(touch) - touch = timestamp_attributes_for_update_in_model if touch == true - touch_time = current_time_from_proper_timezone - Array(touch).map { |column| [ column, touch_time ] }.to_h - end end private - - def _create_record(*) + def _create_record(attribute_names = self.attribute_names) id = super each_counter_cached_associations do |association| if send(association.reflection.name) association.increment_counters - @_after_create_counter_called = true end end diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb new file mode 100644 index 0000000000..9aabde676a --- /dev/null +++ b/activerecord/lib/active_record/database_configurations.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "active_record/database_configurations/database_config" +require "active_record/database_configurations/hash_config" +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. + class DatabaseConfigurations + attr_reader :configurations + + def initialize(configurations = {}) + @configurations = build_configs(configurations) + end + + # Collects the configs for the environment and optionally the specification + # name passed in. To include replica configurations pass `include_replicas: true`. + # + # If a spec name is provided a single DatabaseConfig object will be + # returned, otherwise an array of DatabaseConfig objects will be + # returned that corresponds with the environment and type requested. + # + # Options: + # + # <tt>env_name:</tt> The environment name. Defaults to nil which will collect + # configs for all environments. + # <tt>spec_name:</tt> The specification name (ie primary, animals, etc.). Defaults + # to +nil+. + # <tt>include_replicas:</tt> Determines whether to include replicas in the + # the returned list. Most of the time we're only iterating over the write + # connection (i.e. migrations don't need to run for the write and read connection). + # Defaults to +false+. + def configs_for(env_name: nil, spec_name: nil, include_replicas: false) + configs = env_with_configs(env_name) + + unless include_replicas + configs = configs.select do |db_config| + !db_config.replica? + end + end + + if spec_name + configs.find do |db_config| + db_config.spec_name == spec_name + end + else + configs + end + end + + # Returns the config hash that corresponds with the environment + # + # If the application has multiple databases `default_hash` will + # return the first config hash for the environment. + # + # { database: "my_db", adapter: "mysql2" } + def default_hash(env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call.to_s) + default = find_db_config(env) + default.config if default + end + alias :[] :default_hash + + # Returns a single DatabaseConfig object based on the requested environment. + # + # If the application has multiple databases `find_db_config` will return + # the first DatabaseConfig for the environment. + def find_db_config(env) + configurations.find do |db_config| + db_config.env_name == env.to_s || + (db_config.for_current_env? && db_config.spec_name == env.to_s) + end + end + + # Returns the DatabaseConfigurations object as a Hash. + def to_h + configs = configurations.reverse.inject({}) do |memo, db_config| + memo.merge(db_config.to_legacy_hash) + end + + Hash[configs.to_a.reverse] + end + + # Checks if the application's configurations are empty. + # + # Aliased to blank? + def empty? + configurations.empty? + end + alias :blank? :empty? + + private + def env_with_configs(env = nil) + if env + configurations.select { |db_config| db_config.env_name == env } + else + configurations + end + end + + def build_configs(configs) + return configs.configurations if configs.is_a?(DatabaseConfigurations) + + build_db_config = configs.each_pair.flat_map do |env_name, config| + walk_configs(env_name, "primary", config) + end.compact + + if url = ENV["DATABASE_URL"] + build_url_config(url, build_db_config) + else + build_db_config + end + end + + def walk_configs(env_name, spec_name, config) + case config + when String + build_db_config_from_string(env_name, spec_name, config) + when Hash + build_db_config_from_hash(env_name, spec_name, config) + end + end + + def build_db_config_from_string(env_name, spec_name, config) + begin + url = config + uri = URI.parse(url) + if uri.try(:scheme) + ActiveRecord::DatabaseConfigurations::UrlConfig.new(env_name, spec_name, url) + end + rescue URI::InvalidURIError + ActiveRecord::DatabaseConfigurations::HashConfig.new(env_name, spec_name, config) + end + end + + def build_db_config_from_hash(env_name, spec_name, config) + if url = config["url"] + config_without_url = config.dup + config_without_url.delete "url" + ActiveRecord::DatabaseConfigurations::UrlConfig.new(env_name, spec_name, url, config_without_url) + elsif config["database"] || (config.size == 1 && config.values.all? { |v| v.is_a? String }) + ActiveRecord::DatabaseConfigurations::HashConfig.new(env_name, spec_name, config) + else + config.each_pair.map do |sub_spec_name, sub_config| + walk_configs(env_name, sub_spec_name, sub_config) + end + end + end + + def build_url_config(url, configs) + env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call.to_s + + if original_config = configs.find(&:for_current_env?) + if original_config.url_config? + configs + else + configs.map do |config| + ActiveRecord::DatabaseConfigurations::UrlConfig.new(env, config.spec_name, url, config.config) + end + end + else + configs + [ActiveRecord::DatabaseConfigurations::UrlConfig.new(env, "primary", url)] + end + end + + def method_missing(method, *args, &blk) + if Hash.method_defined?(method) + ActiveSupport::Deprecation.warn \ + "Returning a hash from ActiveRecord::Base.configurations is deprecated. Therefore calling `#{method}` on the hash is also deprecated. Please switch to using the `configs_for` method instead to collect and iterate over database configurations." + end + + case method + when :each, :first + configurations.send(method, *args, &blk) + when :fetch + configs_for(env_name: args.first) + when :values + configurations.map(&:config) + else + super + end + end + end +end diff --git a/activerecord/lib/active_record/database_configurations/database_config.rb b/activerecord/lib/active_record/database_configurations/database_config.rb new file mode 100644 index 0000000000..6250827b34 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/database_config.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActiveRecord + class DatabaseConfigurations + # ActiveRecord::Base.configurations will return either a HashConfig or + # UrlConfig respectively. It will never return a DatabaseConfig object, + # as this is the parent class for the types of database configuration objects. + class DatabaseConfig # :nodoc: + attr_reader :env_name, :spec_name + + def initialize(env_name, spec_name) + @env_name = env_name + @spec_name = spec_name + end + + def replica? + raise NotImplementedError + end + + def url_config? + false + end + + def to_legacy_hash + { env_name => config } + end + + def for_current_env? + env_name == ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + end + end + end +end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb new file mode 100644 index 0000000000..13ffe566cf --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ActiveRecord + class DatabaseConfigurations + # A HashConfig object is created for each database configuration entry that + # is created from a hash. + # + # A hash config: + # + # { "development" => { "database" => "db_name" } } + # + # Becomes: + # + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 + # @env_name="development", @spec_name="primary", @config={"database"=>"db_name"}> + # + # Options are: + # + # <tt>:env_name</tt> - The Rails environment, ie "development" + # <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. + class HashConfig < DatabaseConfig + attr_reader :config + + def initialize(env_name, spec_name, config) + super(env_name, spec_name) + @config = config + end + + # Determines whether a database configuration is for a replica / readonly + # connection. If the `replica` key is present in the config, `replica?` will + # return +true+. + def replica? + config["replica"] + end + end + end +end diff --git a/activerecord/lib/active_record/database_configurations/url_config.rb b/activerecord/lib/active_record/database_configurations/url_config.rb new file mode 100644 index 0000000000..f526c59d56 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/url_config.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module ActiveRecord + class DatabaseConfigurations + # A UrlConfig object is created for each database configuration + # entry that is created from a URL. This can either be a URL string + # or a hash with a URL in place of the config hash. + # + # A URL config: + # + # postgres://localhost/foo + # + # Becomes: + # + # #<ActiveRecord::DatabaseConfigurations::UrlConfig:0x00007fdc3238f340 + # @env_name="default_env", @spec_name="primary", + # @config={"adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost"}, + # @url="postgres://localhost/foo"> + # + # Options are: + # + # <tt>:env_name</tt> - The Rails environment, ie "development" + # <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # <tt>:url</tt> - The database URL. + # <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. + class UrlConfig < DatabaseConfig + attr_reader :url, :config + + def initialize(env_name, spec_name, url, config = {}) + super(env_name, spec_name) + @config = build_config(config, url) + @url = url + end + + def url_config? # :nodoc: + true + end + + # Determines whether a database configuration is for a replica / readonly + # connection. If the `replica` key is present in the config, `replica?` will + # return +true+. + def replica? + config["replica"] + end + + private + def build_config(original_config, url) + if /^jdbc:/.match?(url) + hash = { "url" => url } + else + hash = ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(url).to_hash + end + + if original_config[env_name] + original_config[env_name].merge(hash) + else + original_config.merge(hash) + end + end + end + end +end diff --git a/activerecord/lib/active_record/define_callbacks.rb b/activerecord/lib/active_record/define_callbacks.rb index 7d955a24be..87ecd7cec5 100644 --- a/activerecord/lib/active_record/define_callbacks.rb +++ b/activerecord/lib/active_record/define_callbacks.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + module ActiveRecord - # This module exists because `ActiveRecord::AttributeMethods::Dirty` needs to - # define callbacks, but continue to have its version of `save` be the super - # method of `ActiveRecord::Callbacks`. This will be removed when the removal + # This module exists because ActiveRecord::AttributeMethods::Dirty needs to + # define callbacks, but continue to have its version of +save+ be the super + # method of ActiveRecord::Callbacks. This will be removed when the removal # of deprecated code removes this need. module DefineCallbacks extend ActiveSupport::Concern diff --git a/activerecord/lib/active_record/dynamic_matchers.rb b/activerecord/lib/active_record/dynamic_matchers.rb index 08d42f3dd4..3bb8c6f4e3 100644 --- a/activerecord/lib/active_record/dynamic_matchers.rb +++ b/activerecord/lib/active_record/dynamic_matchers.rb @@ -1,16 +1,16 @@ +# frozen_string_literal: true module ActiveRecord module DynamicMatchers #:nodoc: - def respond_to_missing?(name, include_private = false) - if self == Base - super - else - match = Method.match(self, name) - match && match.valid? || super - end - end - private + def respond_to_missing?(name, _) + if self == Base + super + else + match = Method.match(self, name) + match && match.valid? || super + end + end def method_missing(name, *arguments, &block) match = Method.match(self, name) diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb index 0ab03b2ab3..23ecb24542 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/object/deep_dup" module ActiveRecord @@ -95,8 +97,7 @@ module ActiveRecord module Enum def self.extended(base) # :nodoc: - base.class_attribute(:defined_enums, instance_writer: false) - base.defined_enums = {} + base.class_attribute(:defined_enums, instance_writer: false, default: {}) end def inherited(base) # :nodoc: @@ -140,10 +141,7 @@ module ActiveRecord end end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :name, :mapping, :subtype end @@ -154,11 +152,12 @@ module ActiveRecord definitions.each do |name, values| # statuses = { } enum_values = ActiveSupport::HashWithIndifferentAccess.new - name = name.to_sym + name = name.to_s # def self.statuses() statuses end - detect_enum_conflict!(name, name.to_s.pluralize, true) - klass.singleton_class.send(:define_method, name.to_s.pluralize) { enum_values } + detect_enum_conflict!(name, name.pluralize, true) + singleton_class.send(:define_method, name.pluralize) { enum_values } + defined_enums[name] = enum_values detect_enum_conflict!(name, name) detect_enum_conflict!(name, "#{name}=") @@ -170,7 +169,7 @@ module ActiveRecord _enum_methods_module.module_eval do pairs = values.respond_to?(:each_pair) ? values.each_pair : values.each_with_index - pairs.each do |value, i| + pairs.each do |label, value| if enum_prefix == true prefix = "#{name}_" elsif enum_prefix @@ -182,23 +181,23 @@ module ActiveRecord suffix = "_#{enum_suffix}" end - value_method_name = "#{prefix}#{value}#{suffix}" - enum_values[value] = i + value_method_name = "#{prefix}#{label}#{suffix}" + enum_values[label] = value + label = label.to_s - # def active?() status == 0 end + # def active?() status == "active" end klass.send(:detect_enum_conflict!, name, "#{value_method_name}?") - define_method("#{value_method_name}?") { self[attr] == value.to_s } + define_method("#{value_method_name}?") { self[attr] == label } - # def active!() update! status: :active end + # def active!() update!(status: 0) end klass.send(:detect_enum_conflict!, name, "#{value_method_name}!") define_method("#{value_method_name}!") { update!(attr => value) } - # scope :active, -> { where status: 0 } + # scope :active, -> { where(status: 0) } klass.send(:detect_enum_conflict!, name, value_method_name, true) klass.scope value_method_name, -> { where(attr => value) } end end - defined_enums[name.to_s] = enum_values end end @@ -219,6 +218,8 @@ module ActiveRecord def detect_enum_conflict!(enum_name, method_name, klass_method = false) if klass_method && dangerous_class_method?(method_name) raise_conflict_error(enum_name, method_name, type: "class") + elsif klass_method && method_defined_within?(method_name, Relation) + raise_conflict_error(enum_name, method_name, type: "class", source: Relation.name) elsif !klass_method && dangerous_attribute_method?(method_name) raise_conflict_error(enum_name, method_name) elsif !klass_method && method_defined_within?(method_name, _enum_methods_module, Module) diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 18fac5af1b..f61bc7b9e8 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record Errors # @@ -105,11 +107,12 @@ module ActiveRecord class WrappedDatabaseException < StatementInvalid end - # Raised when a record cannot be inserted because it would violate a uniqueness constraint. + # Raised when a record cannot be inserted or updated because it would violate a uniqueness constraint. class RecordNotUnique < WrappedDatabaseException end - # Raised when a record cannot be inserted or updated because it references a non-existent record. + # Raised when a record cannot be inserted or updated because it references a non-existent record, + # or when a record cannot be deleted because a parent record references it. class InvalidForeignKey < WrappedDatabaseException end @@ -118,13 +121,13 @@ module ActiveRecord def initialize(adapter = nil, message: nil, table: nil, foreign_key: nil, target_table: nil, primary_key: nil) @adapter = adapter if table - msg = <<-EOM.strip_heredoc + msg = +<<~EOM Column `#{foreign_key}` on table `#{table}` has a type of `#{column_type(table, foreign_key)}`. This does not match column `#{primary_key}` on `#{target_table}`, which has type `#{column_type(target_table, primary_key)}`. To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :integer. (For example `t.integer #{foreign_key}`). EOM else - msg = <<-EOM + msg = +<<~EOM There is a mismatch between the foreign key and primary key column types. Verify that the foreign key column type and the primary key of the associated table match types. EOM @@ -167,7 +170,7 @@ module ActiveRecord class NoDatabaseError < StatementInvalid end - # Raised when Postgres returns 'cached plan must not change result type' and + # Raised when PostgreSQL returns 'cached plan must not change result type' and # we cannot retry gracefully (e.g. inside a transaction) class PreparedStatementCacheExpired < StatementInvalid end @@ -313,7 +316,7 @@ module ActiveRecord # # See the following: # - # * http://www.postgresql.org/docs/current/static/transaction-iso.html + # * https://www.postgresql.org/docs/current/static/transaction-iso.html # * https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html#error_er_lock_deadlock class TransactionRollbackError < StatementInvalid end @@ -332,4 +335,41 @@ module ActiveRecord # +reverse_order+ to automatically reverse. class IrreversibleOrderError < ActiveRecordError end + + # LockWaitTimeout will be raised when lock wait timeout exceeded. + class LockWaitTimeout < StatementInvalid + end + + # StatementTimeout will be raised when statement timeout exceeded. + class StatementTimeout < StatementInvalid + end + + # QueryCanceled will be raised when canceling statement due to user request. + class QueryCanceled < StatementInvalid + end + + # UnknownAttributeReference is raised when an unknown and potentially unsafe + # value is passed to a query method when allow_unsafe_raw_sql is set to + # :disabled. For example, passing a non column name value to a relation's + # #order method might cause this exception. + # + # When working around this exception, caution should be taken to avoid SQL + # injection vulnerabilities when passing user-provided values to query + # methods. Known-safe values can be passed to query methods by wrapping them + # in Arel.sql. + # + # For example, with allow_unsafe_raw_sql set to :disabled, the following + # code would raise this exception: + # + # Post.order("length(title)").first + # + # The desired result can be accomplished by wrapping the known-safe string + # in Arel.sql: + # + # Post.order(Arel.sql("length(title)")).first + # + # Again, such a workaround should *not* be used when passing user-provided + # values, such as request parameters or model attributes to query methods. + class UnknownAttributeReference < ActiveRecordError + end end diff --git a/activerecord/lib/active_record/explain.rb b/activerecord/lib/active_record/explain.rb index 8f7ae2c33c..7ccb938888 100644 --- a/activerecord/lib/active_record/explain.rb +++ b/activerecord/lib/active_record/explain.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/explain_registry" module ActiveRecord @@ -16,7 +18,7 @@ module ActiveRecord # Returns a formatted string ready to be logged. def exec_explain(queries) # :nodoc: str = queries.map do |sql, binds| - msg = "EXPLAIN for: #{sql}" + msg = "EXPLAIN for: #{sql}".dup unless binds.empty? msg << " " msg << binds.map { |attr| render_bind(attr) }.inspect diff --git a/activerecord/lib/active_record/explain_registry.rb b/activerecord/lib/active_record/explain_registry.rb index ef1ce3dc85..7fd078941a 100644 --- a/activerecord/lib/active_record/explain_registry.rb +++ b/activerecord/lib/active_record/explain_registry.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/per_thread_registry" module ActiveRecord diff --git a/activerecord/lib/active_record/explain_subscriber.rb b/activerecord/lib/active_record/explain_subscriber.rb index abd8cfc8f2..a86217abc0 100644 --- a/activerecord/lib/active_record/explain_subscriber.rb +++ b/activerecord/lib/active_record/explain_subscriber.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/notifications" require "active_record/explain_registry" diff --git a/activerecord/lib/active_record/fixture_set/file.rb b/activerecord/lib/active_record/fixture_set/file.rb index 6cf2e01179..f1ea0e022f 100644 --- a/activerecord/lib/active_record/fixture_set/file.rb +++ b/activerecord/lib/active_record/fixture_set/file.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "erb" require "yaml" diff --git a/activerecord/lib/active_record/fixtures.rb b/activerecord/lib/active_record/fixtures.rb index e79167d568..6e0f1a0dfb 100644 --- a/activerecord/lib/active_record/fixtures.rb +++ b/activerecord/lib/active_record/fixtures.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "erb" require "yaml" require "zlib" @@ -70,13 +72,32 @@ module ActiveRecord # test. To ensure consistent data, the environment deletes the fixtures before running the load. # # In addition to being available in the database, the fixture's data may also be accessed by - # using a special dynamic method, which has the same name as the model, and accepts the - # name of the fixture to instantiate: + # using a special dynamic method, which has the same name as the model. + # + # Passing in a fixture name to this dynamic method returns the fixture matching this name: # - # test "find" do + # test "find one" do # assert_equal "Ruby on Rails", web_sites(:rubyonrails).name # end # + # Passing in multiple fixture names returns all fixtures matching these names: + # + # test "find all by name" do + # assert_equal 2, web_sites(:rubyonrails, :google).length + # end + # + # Passing in no arguments returns all fixtures: + # + # test "find all" do + # assert_equal 2, web_sites.length + # end + # + # Passing in any fixture name that does not exist will raise <tt>StandardError</tt>: + # + # test "find by name that does not exist" do + # assert_raise(StandardError) { web_sites(:reddit) } + # end + # # Alternatively, you may enable auto-instantiation of the fixture data. For instance, take the # following tests: # @@ -126,7 +147,7 @@ module ActiveRecord # unwanted inter-test dependencies. Methods used by multiple fixtures should be defined in a module # that is included in ActiveRecord::FixtureSet.context_class. # - # - define a helper method in `test_helper.rb` + # - define a helper method in <tt>test_helper.rb</tt> # module FixtureFileHelpers # def file_sha(path) # Digest::SHA2.hexdigest(File.read(Rails.root.join('test/fixtures', path))) @@ -148,18 +169,18 @@ module ActiveRecord # self.use_transactional_tests = true # # test "godzilla" do - # assert !Foo.all.empty? + # assert_not_empty Foo.all # Foo.destroy_all - # assert Foo.all.empty? + # assert_empty Foo.all # end # # test "godzilla aftermath" do - # assert !Foo.all.empty? + # assert_not_empty Foo.all # end # end # - # If you preload your test database with all fixture data (probably in the rake task) and use - # transactional tests, then you may omit all fixtures declarations in your test cases since + # If you preload your test database with all fixture data (probably by running `rails db:fixtures:load`) + # and use transactional tests, then you may omit all fixtures declarations in your test cases since # all the data's already there and every case rolls back its changes. # # In order to use instantiated fixtures with preloaded data, set +self.pre_loaded_fixtures+ to @@ -473,8 +494,7 @@ module ActiveRecord end end - cattr_accessor :all_loaded_fixtures - self.all_loaded_fixtures = {} + cattr_accessor :all_loaded_fixtures, default: {} class ClassCache def initialize(class_names, config) @@ -520,49 +540,38 @@ module ActiveRecord } unless files_to_read.empty? - connection.disable_referential_integrity do - fixtures_map = {} - - fixture_sets = files_to_read.map do |fs_name| - klass = class_names[fs_name] - conn = klass ? klass.connection : connection - fixtures_map[fs_name] = new( # ActiveRecord::FixtureSet.new - conn, - fs_name, - klass, - ::File.join(fixtures_directory, fs_name)) - end - - update_all_loaded_fixtures fixtures_map - - connection.transaction(requires_new: true) do - deleted_tables = Hash.new { |h, k| h[k] = Set.new } - fixture_sets.each do |fs| - conn = fs.model_class.respond_to?(:connection) ? fs.model_class.connection : connection - table_rows = fs.table_rows + fixtures_map = {} + + fixture_sets = files_to_read.map do |fs_name| + klass = class_names[fs_name] + conn = klass ? klass.connection : connection + fixtures_map[fs_name] = new( # ActiveRecord::FixtureSet.new + conn, + fs_name, + klass, + ::File.join(fixtures_directory, fs_name)) + end - table_rows.each_key do |table| - unless deleted_tables[conn].include? table - conn.delete "DELETE FROM #{conn.quote_table_name(table)}", "Fixture Delete" - end - deleted_tables[conn] << table - end + update_all_loaded_fixtures fixtures_map + fixture_sets_by_connection = fixture_sets.group_by { |fs| fs.model_class ? fs.model_class.connection : connection } - table_rows.each do |fixture_set_name, rows| - rows.each do |row| - conn.insert_fixture(row, fixture_set_name) - end - end + fixture_sets_by_connection.each do |conn, set| + table_rows_for_connection = Hash.new { |h, k| h[k] = [] } - # Cap primary key sequences to max(pk). - if conn.respond_to?(:reset_pk_sequence!) - conn.reset_pk_sequence!(fs.table_name) - end + set.each do |fs| + fs.table_rows.each do |table, rows| + table_rows_for_connection[table].unshift(*rows) end end + conn.insert_fixtures_set(table_rows_for_connection, table_rows_for_connection.keys) - cache_fixtures(connection, fixtures_map) + # Cap primary key sequences to max(pk). + if conn.respond_to?(:reset_pk_sequence!) + set.each { |fs| conn.reset_pk_sequence!(fs.table_name) } + end end + + cache_fixtures(connection, fixtures_map) end cached_fixtures(connection, fixture_set_names) end @@ -859,20 +868,13 @@ module ActiveRecord included do class_attribute :fixture_path, instance_writer: false - class_attribute :fixture_table_names - class_attribute :fixture_class_names - class_attribute :use_transactional_tests - class_attribute :use_instantiated_fixtures # true, false, or :no_instances - class_attribute :pre_loaded_fixtures - class_attribute :config - - self.fixture_table_names = [] - self.use_instantiated_fixtures = false - self.pre_loaded_fixtures = false - self.config = ActiveRecord::Base - - self.fixture_class_names = {} - self.use_transactional_tests = true + class_attribute :fixture_table_names, default: [] + class_attribute :fixture_class_names, default: {} + class_attribute :use_transactional_tests, default: true + class_attribute :use_instantiated_fixtures, default: false # true, false, or :no_instances + class_attribute :pre_loaded_fixtures, default: false + class_attribute :config, default: ActiveRecord::Base + class_attribute :lock_threads, default: true end module ClassMethods @@ -909,6 +911,8 @@ module ActiveRecord define_method(accessor_name) do |*fixture_names| force_reload = fixture_names.pop if fixture_names.last == true || fixture_names.last == :reload + return_single_record = fixture_names.size == 1 + fixture_names = @loaded_fixtures[fs_name].fixtures.keys if fixture_names.empty? @fixture_cache[fs_name] ||= {} @@ -923,7 +927,7 @@ module ActiveRecord end end - instances.size == 1 ? instances.first : instances + return_single_record ? instances.first : instances end private accessor_name end @@ -970,7 +974,7 @@ module ActiveRecord @fixture_connections = enlist_fixture_connections @fixture_connections.each do |connection| connection.begin_transaction joinable: false - connection.pool.lock_thread = true + connection.pool.lock_thread = true if lock_threads end # When connections are established in the future, begin a transaction too @@ -986,7 +990,7 @@ module ActiveRecord if connection && !@fixture_connections.include?(connection) connection.begin_transaction joinable: false - connection.pool.lock_thread = true + connection.pool.lock_thread = true if lock_threads @fixture_connections << connection end end @@ -1053,6 +1057,10 @@ class ActiveRecord::FixtureSet::RenderContext # :nodoc: def get_binding binding() end + + def binary(path) + %(!!binary "#{Base64.strict_encode64(File.read(path))}") + end end end end diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 174f716152..72035a986b 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # Returns the version of the currently loaded Active Record as a <tt>Gem::Version</tt> def self.gem_version @@ -5,10 +7,10 @@ module ActiveRecord end module VERSION - MAJOR = 5 - MINOR = 1 + MAJOR = 6 + MINOR = 0 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/inheritance.rb b/activerecord/lib/active_record/inheritance.rb index fbdaeaae51..b25057acda 100644 --- a/activerecord/lib/active_record/inheritance.rb +++ b/activerecord/lib/active_record/inheritance.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/hash/indifferent_access" module ActiveRecord @@ -30,7 +32,7 @@ module ActiveRecord # for differentiating between them or reloading the right type with find. # # Note, all the attributes for all the cases are kept in the same table. Read more: - # http://www.martinfowler.com/eaaCatalog/singleTableInheritance.html + # https://www.martinfowler.com/eaaCatalog/singleTableInheritance.html # module Inheritance extend ActiveSupport::Concern @@ -38,30 +40,32 @@ module ActiveRecord included do # Determines whether to store the full constant name including namespace when using STI. # This is true, by default. - class_attribute :store_full_sti_class, instance_writer: false - self.store_full_sti_class = true + class_attribute :store_full_sti_class, instance_writer: false, default: true end module ClassMethods # Determines if one of the attributes passed in is the inheritance column, # and if the inheritance column is attr accessible, it initializes an # instance of the given subclass instead of the base class. - def new(*args, &block) + def new(attributes = nil, &block) if abstract_class? || self == Base raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated." end - attrs = args.first if has_attribute?(inheritance_column) - subclass = subclass_from_attributes(attrs) + subclass = subclass_from_attributes(attributes) + + if subclass.nil? && scope_attributes = current_scope&.scope_for_create + subclass = subclass_from_attributes(scope_attributes) + end - if subclass.nil? && base_class == self + if subclass.nil? && base_class? subclass = subclass_from_attributes(column_defaults) end end if subclass && subclass != self - subclass.new(*args, &block) + subclass.new(attributes, &block) else super end @@ -104,21 +108,53 @@ module ActiveRecord end end - # Set this to true if this is an abstract class (see <tt>abstract_class?</tt>). - # If you are using inheritance with ActiveRecord and don't want child classes - # to utilize the implied STI table name of the parent class, this will need to be true. - # For example, given the following: + # Returns whether the class is a base class. + # See #base_class for more information. + def base_class? + base_class == self + end + + # Set this to +true+ if this is an abstract class (see + # <tt>abstract_class?</tt>). + # If you are using inheritance with Active Record and don't want a class + # to be considered as part of the STI hierarchy, you must set this to + # true. + # +ApplicationRecord+, for example, is generated as an abstract class. + # + # Consider the following default behaviour: + # + # Shape = Class.new(ActiveRecord::Base) + # Polygon = Class.new(Shape) + # Square = Class.new(Polygon) # - # class SuperClass < ActiveRecord::Base + # Shape.table_name # => "shapes" + # Polygon.table_name # => "shapes" + # Square.table_name # => "shapes" + # Shape.create! # => #<Shape id: 1, type: nil> + # Polygon.create! # => #<Polygon id: 2, type: "Polygon"> + # Square.create! # => #<Square id: 3, type: "Square"> + # + # However, when using <tt>abstract_class</tt>, +Shape+ is omitted from + # the hierarchy: + # + # class Shape < ActiveRecord::Base # self.abstract_class = true # end - # class Child < SuperClass - # self.table_name = 'the_table_i_really_want' - # end + # Polygon = Class.new(Shape) + # Square = Class.new(Polygon) # + # Shape.table_name # => nil + # Polygon.table_name # => "polygons" + # Square.table_name # => "polygons" + # Shape.create! # => NotImplementedError: Shape is an abstract class and cannot be instantiated. + # Polygon.create! # => #<Polygon id: 1, type: nil> + # Square.create! # => #<Square id: 2, type: "Square"> # - # <tt>self.abstract_class = true</tt> is required to make <tt>Child<.find,.create, or any Arel method></tt> use <tt>the_table_i_really_want</tt> instead of a table called <tt>super_classes</tt> - # + # Note that in the above example, to disallow the creation of a plain + # +Polygon+, you should use <tt>validates :type, presence: true</tt>, + # instead of setting it as an abstract class. This way, +Polygon+ will + # stay in the hierarchy, and Active Record will continue to correctly + # derive the table name. attr_accessor :abstract_class # Returns whether this class is an abstract class or not. @@ -130,6 +166,10 @@ module ActiveRecord store_full_sti_class ? name : name.demodulize end + def polymorphic_name + base_class.name + end + def inherited(subclass) subclass.instance_variable_set(:@_type_candidates_cache, Concurrent::Map.new) super @@ -217,7 +257,7 @@ module ActiveRecord def subclass_from_attributes(attrs) attrs = attrs.to_h if attrs.respond_to?(:permitted?) if attrs.is_a?(Hash) - subclass_name = attrs.with_indifferent_access[inheritance_column] + subclass_name = attrs[inheritance_column] || attrs[inheritance_column.to_sym] if subclass_name.present? find_sti_class(subclass_name) @@ -246,7 +286,7 @@ module ActiveRecord def ensure_proper_type klass = self.class if klass.finder_needs_type_condition? - write_attribute(klass.inheritance_column, klass.sti_name) + _write_attribute(klass.inheritance_column, klass.sti_name) end end end diff --git a/activerecord/lib/active_record/integration.rb b/activerecord/lib/active_record/integration.rb index 8e71b60b29..6cf26a9792 100644 --- a/activerecord/lib/active_record/integration.rb +++ b/activerecord/lib/active_record/integration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/string/filters" module ActiveRecord @@ -7,12 +9,19 @@ module ActiveRecord included do ## # :singleton-method: - # Indicates the format used to generate the timestamp in the cache key. - # Accepts any of the symbols in <tt>Time::DATE_FORMATS</tt>. + # Indicates the format used to generate the timestamp in the cache key, if + # versioning is off. Accepts any of the symbols in <tt>Time::DATE_FORMATS</tt>. # # This is +:usec+, by default. - class_attribute :cache_timestamp_format, instance_writer: false - self.cache_timestamp_format = :usec + class_attribute :cache_timestamp_format, instance_writer: false, default: :usec + + ## + # :singleton-method: + # Indicates whether to use a stable #cache_key method that is accompanied + # by a changing version in the #cache_version method. + # + # This is +false+, by default until Rails 6.0. + class_attribute :cache_versioning, instance_writer: false, default: false end # Returns a +String+, which Action Pack uses for constructing a URL to this @@ -42,35 +51,65 @@ module ActiveRecord id && id.to_s # Be sure to stringify the id for routes end - # Returns a cache key that can be used to identify this record. + # Returns a stable cache key that can be used to identify this record. # # Product.new.cache_key # => "products/new" - # Product.find(5).cache_key # => "products/5" (updated_at not available) - # Person.find(5).cache_key # => "people/5-20071224150000" (updated_at available) + # Product.find(5).cache_key # => "products/5" # - # You can also pass a list of named timestamps, and the newest in the list will be - # used to generate the key: + # If ActiveRecord::Base.cache_versioning is turned off, as it was in Rails 5.1 and earlier, + # the cache key will also include a version. # - # Person.find(5).cache_key(:updated_at, :last_reviewed_at) + # Product.cache_versioning = false + # Person.find(5).cache_key # => "people/5-20071224150000" (updated_at available) def cache_key(*timestamp_names) if new_record? "#{model_name.cache_key}/new" else - timestamp = if timestamp_names.any? - max_updated_column_timestamp(timestamp_names) + if cache_version && timestamp_names.none? + "#{model_name.cache_key}/#{id}" else - max_updated_column_timestamp - end + timestamp = if timestamp_names.any? + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Specifying a timestamp name for #cache_key has been deprecated in favor of + the explicit #cache_version method that can be overwritten. + MSG - if timestamp - timestamp = timestamp.utc.to_s(cache_timestamp_format) - "#{model_name.cache_key}/#{id}-#{timestamp}" - else - "#{model_name.cache_key}/#{id}" + max_updated_column_timestamp(timestamp_names) + else + max_updated_column_timestamp + end + + if timestamp + timestamp = timestamp.utc.to_s(cache_timestamp_format) + "#{model_name.cache_key}/#{id}-#{timestamp}" + else + "#{model_name.cache_key}/#{id}" + end end end end + # Returns a cache version that can be used together with the cache key to form + # a recyclable caching scheme. By default, the #updated_at column is used for the + # cache_version, but this method can be overwritten to return something else. + # + # Note, this method will return nil if ActiveRecord::Base.cache_versioning is set to + # +false+ (which it is by default until Rails 6.0). + def cache_version + if cache_versioning && timestamp = try(:updated_at) + timestamp.utc.to_s(:usec) + end + end + + # Returns a cache key along with the version. + def cache_key_with_version + if version = cache_version + "#{cache_key}-#{version}" + else + cache_key + end + end + module ClassMethods # Defines your model's +to_param+ method to generate "pretty" URLs # using +method_name+, which can be any attribute or method that diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index 25ee9d6bfe..3626a13d7c 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/scoping/default" require "active_record/scoping/named" @@ -15,7 +17,7 @@ module ActiveRecord end def []=(key, value) - find_or_initialize_by(key: key).update_attributes!(value: value) + find_or_initialize_by(key: key).update!(value: value) end def [](key) diff --git a/activerecord/lib/active_record/legacy_yaml_adapter.rb b/activerecord/lib/active_record/legacy_yaml_adapter.rb index c7683f68c7..ffa095dd94 100644 --- a/activerecord/lib/active_record/legacy_yaml_adapter.rb +++ b/activerecord/lib/active_record/legacy_yaml_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module LegacyYamlAdapter def self.convert(klass, coder) @@ -6,7 +8,7 @@ module ActiveRecord case coder["active_record_yaml_version"] when 1, 2 then coder else - if coder["attributes"].is_a?(AttributeSet) + if coder["attributes"].is_a?(ActiveModel::AttributeSet) Rails420.convert(klass, coder) else Rails41.convert(klass, coder) diff --git a/activerecord/lib/active_record/locking/optimistic.rb b/activerecord/lib/active_record/locking/optimistic.rb index 2659c60f1f..4a3a31fc95 100644 --- a/activerecord/lib/active_record/locking/optimistic.rb +++ b/activerecord/lib/active_record/locking/optimistic.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Locking # == What is Optimistic Locking @@ -47,14 +49,11 @@ module ActiveRecord # self.locking_column = :lock_person # end # - # Please note that the optimistic locking will be ignored if you update the - # locking column's value. module Optimistic extend ActiveSupport::Concern included do - class_attribute :lock_optimistically, instance_writer: false - self.lock_optimistically = true + class_attribute :lock_optimistically, instance_writer: false, default: true end def locking_enabled? #:nodoc: @@ -62,14 +61,7 @@ module ActiveRecord end private - - def increment_lock - lock_col = self.class.locking_column - previous_lock_value = send(lock_col).to_i - send(lock_col + "=", previous_lock_value + 1) - end - - def _create_record(attribute_names = self.attribute_names, *) + def _create_record(attribute_names = self.attribute_names) if locking_enabled? # We always want to persist the locking version, even if we don't detect # a change from the default, since the database might have no default @@ -78,64 +70,56 @@ module ActiveRecord super end - def _update_record(attribute_names = self.attribute_names) - return super unless locking_enabled? - - lock_col = self.class.locking_column + def _touch_row(attribute_names, time) + super + ensure + clear_attribute_change(self.class.locking_column) if locking_enabled? + end - return super if attribute_names.include?(lock_col) - return 0 if attribute_names.empty? + def _update_row(attribute_names, attempted_action = "update") + return super unless locking_enabled? begin - previous_lock_value = read_attribute_before_type_cast(lock_col) - - increment_lock - - attribute_names.push(lock_col) + locking_column = self.class.locking_column + previous_lock_value = read_attribute_before_type_cast(locking_column) + attribute_names << locking_column - relation = self.class.unscoped + self[locking_column] += 1 - affected_rows = relation.where( - self.class.primary_key => id, - lock_col => previous_lock_value - ).update_all( - attributes_for_update(attribute_names).map do |name| - [name, _read_attribute(name)] - end.to_h + affected_rows = self.class._update_record( + attributes_with_values(attribute_names), + self.class.primary_key => id_in_database, + locking_column => previous_lock_value ) - unless affected_rows == 1 - raise ActiveRecord::StaleObjectError.new(self, "update") + if affected_rows != 1 + raise ActiveRecord::StaleObjectError.new(self, attempted_action) end affected_rows # If something went wrong, revert the locking_column value. rescue Exception - send(lock_col + "=", previous_lock_value.to_i) + self[locking_column] = previous_lock_value.to_i raise end end def destroy_row - affected_rows = super + return super unless locking_enabled? - if locking_enabled? && affected_rows != 1 - raise ActiveRecord::StaleObjectError.new(self, "destroy") - end + locking_column = self.class.locking_column - affected_rows - end + affected_rows = self.class._delete_record( + self.class.primary_key => id_in_database, + locking_column => read_attribute_before_type_cast(locking_column) + ) - def relation_for_destroy - relation = super - - if locking_enabled? - locking_column = self.class.locking_column - relation = relation.where(locking_column => _read_attribute(locking_column)) + if affected_rows != 1 + raise ActiveRecord::StaleObjectError.new(self, "destroy") end - relation + affected_rows end module ClassMethods @@ -181,7 +165,7 @@ module ActiveRecord def inherited(subclass) subclass.class_eval do is_lock_column = ->(name, _) { lock_optimistically && name == locking_column } - decorate_matching_attribute_types(is_lock_column, :_optimistic_locking) do |type| + decorate_matching_attribute_types(is_lock_column, "_optimistic_locking") do |type| LockingType.new(type) end end diff --git a/activerecord/lib/active_record/locking/pessimistic.rb b/activerecord/lib/active_record/locking/pessimistic.rb index 263e2a5f7f..5d1d15c94d 100644 --- a/activerecord/lib/active_record/locking/pessimistic.rb +++ b/activerecord/lib/active_record/locking/pessimistic.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Locking # Locking::Pessimistic provides support for row-level locking using @@ -51,8 +53,8 @@ module ActiveRecord # end # # Database-specific information on row locking: - # MySQL: http://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html - # PostgreSQL: http://www.postgresql.org/docs/current/interactive/sql-select.html#SQL-FOR-UPDATE-SHARE + # MySQL: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html + # PostgreSQL: https://www.postgresql.org/docs/current/interactive/sql-select.html#SQL-FOR-UPDATE-SHARE module Pessimistic # Obtain a row lock on this record. Reloads the record to obtain the requested # lock. Pass an SQL locking clause to append the end of the SELECT statement @@ -60,13 +62,14 @@ module ActiveRecord # the locked record. def lock!(lock = true) if persisted? - if changed? - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Locking a record with unpersisted changes is deprecated and will raise an - exception in Rails 5.2. Use `save` to persist the changes, or `reload` to - discard them explicitly. + if has_changes_to_save? + raise(<<-MSG.squish) + Locking a record with unpersisted changes is not supported. Use + `save` to persist the changes, or `reload` to discard them + explicitly. MSG end + reload(lock: lock) end self diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index ea101946f4..6b84431343 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord class LogSubscriber < ActiveSupport::LogSubscriber IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"] + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + def self.runtime=(value) ActiveRecord::RuntimeRegistry.sql_runtime = value end @@ -29,7 +33,7 @@ module ActiveRecord binds = nil unless (payload[:binds] || []).empty? - casted_params = type_casted_binds(payload[:binds], payload[:type_casted_binds]) + casted_params = type_casted_binds(payload[:type_casted_binds]) binds = " " + payload[:binds].zip(casted_params).map { |attr, value| render_bind(attr, value) }.inspect @@ -42,19 +46,18 @@ module ActiveRecord end private - - def type_casted_binds(binds, casted_binds) - casted_binds || binds.map { |attr| type_cast attr.value_for_database } + def type_casted_binds(casted_binds) + casted_binds.respond_to?(:call) ? casted_binds.call : casted_binds end - def render_bind(attr, type_casted_value) - value = if attr.type.binary? && attr.value - "<#{attr.value_for_database.to_s.bytesize} bytes of binary data>" - else - type_casted_value + def render_bind(attr, value) + if attr.is_a?(Array) + attr = attr.first + elsif attr.type.binary? && attr.value + value = "<#{attr.value_for_database.to_s.bytesize} bytes of binary data>" end - [attr.name, value] + [attr && attr.name, value] end def colorize_payload_name(name, payload_name) @@ -90,8 +93,24 @@ module ActiveRecord ActiveRecord::Base.logger end - def type_cast(value) - ActiveRecord::Base.connection.type_cast(value) + def debug(progname = nil, &block) + return unless super + + if ActiveRecord::Base.verbose_query_logs + log_query_source + end + end + + def log_query_source + source = extract_query_source_location(caller) + + if source + logger.debug(" ↳ #{source}") + end + end + + def extract_query_source_location(locations) + backtrace_cleaner.clean(locations).first end end end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 263a8a7da3..ea53324829 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +require "benchmark" require "set" require "zlib" require "active_support/core_ext/module/attribute_accessors" @@ -127,9 +130,9 @@ module ActiveRecord class PendingMigrationError < MigrationError#:nodoc: def initialize(message = nil) if !message && defined?(Rails.env) - super("Migrations are pending. To resolve this issue, run:\n\n bin/rails db:migrate RAILS_ENV=#{::Rails.env}") + super("Migrations are pending. To resolve this issue, run:\n\n rails db:migrate RAILS_ENV=#{::Rails.env}") elsif !message - super("Migrations are pending. To resolve this issue, run:\n\n bin/rails db:migrate") + super("Migrations are pending. To resolve this issue, run:\n\n rails db:migrate") else super end @@ -138,6 +141,7 @@ module ActiveRecord class ConcurrentMigrationError < MigrationError #:nodoc: DEFAULT_MESSAGE = "Cannot run migrations because another migration process is currently running.".freeze + RELEASE_LOCK_FAILED_MESSAGE = "Failed to release advisory lock".freeze def initialize(message = DEFAULT_MESSAGE) super @@ -146,7 +150,7 @@ module ActiveRecord class NoEnvironmentInSchemaError < MigrationError #:nodoc: def initialize - msg = "Environment data not found in the schema. To resolve this issue, run: \n\n bin/rails db:environment:set" + msg = "Environment data not found in the schema. To resolve this issue, run: \n\n rails db:environment:set" if defined?(Rails.env) super("#{msg} RAILS_ENV=#{::Rails.env}") else @@ -157,7 +161,7 @@ module ActiveRecord class ProtectedEnvironmentError < ActiveRecordError #:nodoc: def initialize(env = "production") - msg = "You are attempting to run a destructive action against your '#{env}' database.\n" + msg = "You are attempting to run a destructive action against your '#{env}' database.\n".dup msg << "If you are sure you want to continue, run the same command with the environment variable:\n" msg << "DISABLE_DATABASE_ENVIRONMENT_CHECK=1" super(msg) @@ -166,10 +170,10 @@ module ActiveRecord class EnvironmentMismatchError < ActiveRecordError def initialize(current: nil, stored: nil) - msg = "You are attempting to modify a database that was last run in `#{ stored }` environment.\n" + msg = "You are attempting to modify a database that was last run in `#{ stored }` environment.\n".dup msg << "You are running in `#{ current }` environment. " msg << "If you are sure you want to continue, first set the environment using:\n\n" - msg << " bin/rails db:environment:set" + msg << " rails db:environment:set" if defined?(Rails.env) super("#{msg} RAILS_ENV=#{::Rails.env}\n\n") else @@ -348,13 +352,13 @@ module ActiveRecord # <tt>rails db:migrate</tt>. This will update the database by running all of the # pending migrations, creating the <tt>schema_migrations</tt> table # (see "About the schema_migrations table" section below) if missing. It will also - # invoke the db:schema:dump task, which will update your db/schema.rb file + # invoke the db:schema:dump command, which will update your db/schema.rb file # to match the structure of your database. # # To roll the database back to a previous migration version, use - # <tt>rails db:migrate VERSION=X</tt> where <tt>X</tt> is the version to which + # <tt>rails db:rollback VERSION=X</tt> where <tt>X</tt> is the version to which # you wish to downgrade. Alternatively, you can also use the STEP option if you - # wish to rollback last few migrations. <tt>rails db:migrate STEP=2</tt> will rollback + # wish to rollback last few migrations. <tt>rails db:rollback STEP=2</tt> will rollback # the latest two migrations. # # If any of the migrations throw an <tt>ActiveRecord::IrreversibleMigration</tt> exception, @@ -548,7 +552,7 @@ module ActiveRecord end def call(env) - mtime = ActiveRecord::Migrator.last_migration.mtime.to_i + mtime = ActiveRecord::Base.connection.migration_context.last_migration.mtime.to_i if @last_check < mtime ActiveRecord::Migration.check_pending!(connection) @last_check = mtime @@ -573,13 +577,14 @@ module ActiveRecord # Raises <tt>ActiveRecord::PendingMigrationError</tt> error if any migrations are pending. def check_pending!(connection = Base.connection) - raise ActiveRecord::PendingMigrationError if ActiveRecord::Migrator.needs_migration?(connection) + raise ActiveRecord::PendingMigrationError if connection.migration_context.needs_migration? end def load_schema_if_pending! - if ActiveRecord::Migrator.needs_migration? || !ActiveRecord::Migrator.any_migrations? + if Base.connection.migration_context.needs_migration? || !Base.connection.migration_context.any_migrations? # Roundtrip to Rake to allow plugins to hook into database initialization. - FileUtils.cd Rails.root do + root = defined?(ENGINE_ROOT) ? ENGINE_ROOT : Rails.root + FileUtils.cd(root) do current_config = Base.connection_config Base.clear_all_connections! system("bin/rails db:test:prepare") @@ -731,6 +736,24 @@ module ActiveRecord execute_block { yield helper } end + # Used to specify an operation that is only run when migrating up + # (for example, populating a new column with its initial values). + # + # In the following example, the new column +published+ will be given + # the value +true+ for all existing records. + # + # class AddPublishedToPosts < ActiveRecord::Migration[5.2] + # def change + # add_column :posts, :published, :boolean, default: false + # up_only do + # execute "update posts set published = 'true'" + # end + # end + # end + def up_only + execute_block { yield } unless reverting? + end + # Runs the given migration classes. # Last argument can specify options: # - :direction (default is :up) @@ -808,10 +831,14 @@ module ActiveRecord write "== %s %s" % [text, "=" * length] end + # Takes a message argument and outputs it as is. + # A second boolean argument can be passed to specify whether to indent or not. def say(message, subitem = false) write "#{subitem ? " ->" : "--"} #{message}" end + # Outputs text along with how long it took to run its block. + # If the block returns an integer it assumes it is the number of rows affected. def say_with_time(message) say(message) result = nil @@ -821,6 +848,7 @@ module ActiveRecord result end + # Takes a block as an argument and suppresses any output generated by the block. def suppress_messages save, self.verbose = verbose, false yield @@ -855,23 +883,25 @@ module ActiveRecord FileUtils.mkdir_p(destination) unless File.exist?(destination) - destination_migrations = ActiveRecord::Migrator.migrations(destination) + destination_migrations = ActiveRecord::MigrationContext.new(destination).migrations last = destination_migrations.last sources.each do |scope, path| - source_migrations = ActiveRecord::Migrator.migrations(path) + source_migrations = ActiveRecord::MigrationContext.new(path).migrations source_migrations.each do |migration| source = File.binread(migration.filename) inserted_comment = "# This migration comes from #{scope} (originally #{migration.version})\n" - if /\A#.*\b(?:en)?coding:\s*\S+/ =~ source + magic_comments = "".dup + loop do # If we have a magic comment in the original migration, # insert our comment after the first newline(end of the magic comment line) # so the magic keep working. # Note that magic comments must be at the first line(except sh-bang). - source[/\n/] = "\n#{inserted_comment}" - else - source = "#{inserted_comment}#{source}" + source.sub!(/\A(?:#.*\b(?:en)?coding:\s*\S+|#\s*frozen_string_literal:\s*(?:true|false)).*\n/) do |magic_comment| + magic_comments << magic_comment; "" + end || break end + source = "#{magic_comments}#{inserted_comment}#{source}" if duplicate = destination_migrations.detect { |m| m.name == migration.name } if options[:on_skip] && duplicate.scope != scope.to_s @@ -974,127 +1004,184 @@ module ActiveRecord end end - class Migrator#:nodoc: - class << self - attr_writer :migrations_paths - alias :migrations_path= :migrations_paths= - - def migrate(migrations_paths, target_version = nil, &block) - case - when target_version.nil? - up(migrations_paths, target_version, &block) - when current_version == 0 && target_version == 0 - [] - when current_version > target_version - down(migrations_paths, target_version, &block) - else - up(migrations_paths, target_version, &block) - end - end + class MigrationContext # :nodoc: + attr_reader :migrations_paths - def rollback(migrations_paths, steps = 1) - move(:down, migrations_paths, steps) - end + def initialize(migrations_paths) + @migrations_paths = migrations_paths + end - def forward(migrations_paths, steps = 1) - move(:up, migrations_paths, steps) + def migrate(target_version = nil, &block) + case + when target_version.nil? + up(target_version, &block) + when current_version == 0 && target_version == 0 + [] + when current_version > target_version + down(target_version, &block) + else + up(target_version, &block) end + end - def up(migrations_paths, target_version = nil) - migrations = migrations(migrations_paths) - migrations.select! { |m| yield m } if block_given? + def rollback(steps = 1) + move(:down, steps) + end - new(:up, migrations, target_version).migrate + def forward(steps = 1) + move(:up, steps) + end + + def up(target_version = nil) + selected_migrations = if block_given? + migrations.select { |m| yield m } + else + migrations end - def down(migrations_paths, target_version = nil) - migrations = migrations(migrations_paths) - migrations.select! { |m| yield m } if block_given? + Migrator.new(:up, selected_migrations, target_version).migrate + end - new(:down, migrations, target_version).migrate + def down(target_version = nil) + selected_migrations = if block_given? + migrations.select { |m| yield m } + else + migrations end - def run(direction, migrations_paths, target_version) - new(direction, migrations(migrations_paths), target_version).run - end + Migrator.new(:down, selected_migrations, target_version).migrate + end - def open(migrations_paths) - new(:up, migrations(migrations_paths), nil) - end + def run(direction, target_version) + Migrator.new(direction, migrations, target_version).run + end - def schema_migrations_table_name - SchemaMigration.table_name - end + def open + Migrator.new(:up, migrations, nil) + end - def get_all_versions(connection = Base.connection) - if connection.table_exists?(schema_migrations_table_name) - SchemaMigration.all.map { |x| x.version.to_i }.sort - else - [] - end + def get_all_versions + if SchemaMigration.table_exists? + SchemaMigration.all_versions.map(&:to_i) + else + [] end + end - def current_version(connection = Base.connection) - get_all_versions(connection).max || 0 - end + def current_version + get_all_versions.max || 0 + rescue ActiveRecord::NoDatabaseError + end - def needs_migration?(connection = Base.connection) - (migrations(migrations_paths).collect(&:version) - get_all_versions(connection)).size > 0 - end + def needs_migration? + (migrations.collect(&:version) - get_all_versions).size > 0 + end - def any_migrations? - migrations(migrations_paths).any? - end + def any_migrations? + migrations.any? + end - def last_migration #:nodoc: - migrations(migrations_paths).last || NullMigration.new - end + def last_migration #:nodoc: + migrations.last || NullMigration.new + end - def migrations_paths - @migrations_paths ||= ["db/migrate"] - # just to not break things if someone uses: migrations_path = some_string - Array(@migrations_paths) - end + def parse_migration_filename(filename) # :nodoc: + File.basename(filename).scan(Migration::MigrationFilenameRegexp).first + end + + def migrations + migrations = migration_files.map do |file| + version, name, scope = parse_migration_filename(file) + raise IllegalMigrationNameError.new(file) unless version + version = version.to_i + name = name.camelize - def match_to_migration_filename?(filename) # :nodoc: - Migration::MigrationFilenameRegexp.match?(File.basename(filename)) + MigrationProxy.new(name, version, file, scope) end - def parse_migration_filename(filename) # :nodoc: - File.basename(filename).scan(Migration::MigrationFilenameRegexp).first + migrations.sort_by(&:version) + end + + def migrations_status + db_list = ActiveRecord::SchemaMigration.normalized_versions + + file_list = migration_files.map do |file| + version, name, scope = parse_migration_filename(file) + raise IllegalMigrationNameError.new(file) unless version + version = ActiveRecord::SchemaMigration.normalize_migration_number(version) + status = db_list.delete(version) ? "up" : "down" + [status, version, (name + scope).humanize] + end.compact + + db_list.map! do |version| + ["up", version, "********** NO FILE **********"] end - def migrations(paths) - paths = Array(paths) + (db_list + file_list).sort_by { |_, version, _| version } + end + + def migration_files + paths = Array(migrations_paths) + Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] + end + + def current_environment + ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + end - files = Dir[*paths.map { |p| "#{p}/**/[0-9]*_*.rb" }] + def protected_environment? + ActiveRecord::Base.protected_environments.include?(last_stored_environment) if last_stored_environment + end - migrations = files.map do |file| - version, name, scope = parse_migration_filename(file) - raise IllegalMigrationNameError.new(file) unless version - version = version.to_i - name = name.camelize + def last_stored_environment + return nil if current_version == 0 + raise NoEnvironmentInSchemaError unless ActiveRecord::InternalMetadata.table_exists? + + environment = ActiveRecord::InternalMetadata[:environment] + raise NoEnvironmentInSchemaError unless environment + environment + end - MigrationProxy.new(name, version, file, scope) + private + def move(direction, steps) + migrator = Migrator.new(direction, migrations) + + if current_version != 0 && !migrator.current_migration + raise UnknownMigrationVersionError.new(current_version) end - migrations.sort_by(&:version) + start_index = + if current_version == 0 + 0 + else + migrator.migrations.index(migrator.current_migration) + end + + finish = migrator.migrations[start_index + steps] + version = finish ? finish.version : 0 + send(direction, version) end + end - private + class Migrator # :nodoc: + class << self + attr_accessor :migrations_paths - def move(direction, migrations_paths, steps) - migrator = new(direction, migrations(migrations_paths)) - start_index = migrator.migrations.index(migrator.current_migration) + def migrations_path=(path) + ActiveSupport::Deprecation.warn \ + "ActiveRecord::Migrator.migrations_paths= is now deprecated and will be removed in Rails 6.0." \ + "You can set the `migrations_paths` on the `connection` instead through the `database.yml`." + self.migrations_paths = [path] + end - if start_index - finish = migrator.migrations[start_index + steps] - version = finish ? finish.version : 0 - send(direction, migrations_paths, version) - end + # For cases where a table doesn't exist like loading from schema cache + def current_version + MigrationContext.new(migrations_paths).current_version end end + self.migrations_paths = ["db/migrate"] + def initialize(direction, migrations, target_version = nil) @direction = direction @target_version = target_version @@ -1157,7 +1244,7 @@ module ActiveRecord end def load_migrated - @migrated_versions = Set.new(self.class.get_all_versions) + @migrated_versions = Set.new(Base.connection.migration_context.get_all_versions) end private @@ -1189,7 +1276,7 @@ module ActiveRecord # Stores the current environment in the database. def record_environment return if down? - ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment + ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Base.connection.migration_context.current_environment end def ran?(migration) @@ -1198,7 +1285,7 @@ module ActiveRecord # Return true if a valid version is not provided. def invalid_target? - !target && @target_version && @target_version > 0 + @target_version && @target_version != 0 && !target end def execute_migration_in_transaction(migration, direction) @@ -1212,7 +1299,7 @@ module ActiveRecord record_version_state_after_migrating(migration.version) end rescue => e - msg = "An error has occurred, " + msg = "An error has occurred, ".dup msg << "this and " if use_transaction?(migration) msg << "all later migrations canceled:\n\n#{e}" raise StandardError, msg, e.backtrace @@ -1231,10 +1318,10 @@ module ActiveRecord end def validate(migrations) - name , = migrations.group_by(&:name).find { |_, v| v.length > 1 } + name, = migrations.group_by(&:name).find { |_, v| v.length > 1 } raise DuplicateMigrationNameError.new(name) if name - version , = migrations.group_by(&:version).find { |_, v| v.length > 1 } + version, = migrations.group_by(&:version).find { |_, v| v.length > 1 } raise DuplicateMigrationVersionError.new(version) if version end @@ -1248,23 +1335,6 @@ module ActiveRecord end end - def self.last_stored_environment - return nil if current_version == 0 - raise NoEnvironmentInSchemaError unless ActiveRecord::InternalMetadata.table_exists? - - environment = ActiveRecord::InternalMetadata[:environment] - raise NoEnvironmentInSchemaError unless environment - environment - end - - def self.current_environment - ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - end - - def self.protected_environment? - ActiveRecord::Base.protected_environments.include?(last_stored_environment) if last_stored_environment - end - def up? @direction == :up end @@ -1287,17 +1357,22 @@ module ActiveRecord end def use_advisory_lock? - Base.connection.supports_advisory_locks? + Base.connection.advisory_locks_enabled? end def with_advisory_lock lock_id = generate_migrator_advisory_lock_id - got_lock = Base.connection.get_advisory_lock(lock_id) + connection = Base.connection + got_lock = connection.get_advisory_lock(lock_id) raise ConcurrentMigrationError unless got_lock load_migrated # reload schema_migrations to be sure it wasn't changed by another process before we got the lock yield ensure - Base.connection.release_advisory_lock(lock_id) if got_lock + if got_lock && !connection.release_advisory_lock(lock_id) + raise ConcurrentMigrationError.new( + ConcurrentMigrationError::RELEASE_LOCK_FAILED_MESSAGE + ) + end end MIGRATOR_SALT = 2053462845 diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index 03103bba98..dea6d4ec08 100644 --- a/activerecord/lib/active_record/migration/command_recorder.rb +++ b/activerecord/lib/active_record/migration/command_recorder.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class Migration # <tt>ActiveRecord::Migration::CommandRecorder</tt> records commands done during @@ -83,7 +85,7 @@ module ActiveRecord # invert the +command+. def inverse_of(command, args, &block) method = :"invert_#{command}" - raise IrreversibleMigration, <<-MSG.strip_heredoc unless respond_to?(method, true) + raise IrreversibleMigration, <<~MSG unless respond_to?(method, true) This migration uses #{command}, which is not automatically reversible. To make the migration reversible you can either: 1. Define #up and #down methods in place of the #change method. @@ -92,10 +94,6 @@ module ActiveRecord send(method, args, &block) end - def respond_to_missing?(*args) # :nodoc: - super || delegate.respond_to?(*args) - end - ReversibleAndIrreversibleMethods.each do |method| class_eval <<-EOV, __FILE__, __LINE__ + 1 def #{method}(*args, &block) # def create_table(*args, &block) @@ -112,7 +110,7 @@ module ActiveRecord private - module StraightReversions + module StraightReversions # :nodoc: private { transaction: :transaction, execute_block: :execute_block, @@ -163,8 +161,8 @@ module ActiveRecord table, columns, options = *args options ||= {} - index_name = options[:name] - options_hash = index_name ? { name: index_name } : { column: columns } + options_hash = options.slice(:name, :algorithm) + options_hash[:column] = columns if !options_hash[:name] [:remove_index, [table, options_hash]] end @@ -216,19 +214,36 @@ module ActiveRecord end def invert_remove_foreign_key(args) - from_table, to_table, remove_options = args - raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? || to_table.is_a?(Hash) + from_table, options_or_to_table, options_or_nil = args + + to_table = if options_or_to_table.is_a?(Hash) + options_or_to_table[:to_table] + else + options_or_to_table + end + + remove_options = if options_or_to_table.is_a?(Hash) + options_or_to_table.except(:to_table) + else + options_or_nil + end + + raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? reversed_args = [from_table, to_table] - reversed_args << remove_options if remove_options + reversed_args << remove_options if remove_options.present? [:add_foreign_key, reversed_args] end + def respond_to_missing?(method, _) + super || delegate.respond_to?(method) + end + # Forwards any missing method call to the \target. def method_missing(method, *args, &block) - if @delegate.respond_to?(method) - @delegate.send(method, *args, &block) + if delegate.respond_to?(method) + delegate.public_send(method, *args, &block) else super end diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 85032ce470..0edaaa0cf9 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class Migration module Compatibility # :nodoc: all @@ -11,10 +13,41 @@ module ActiveRecord const_get(name) end - V5_1 = Current + V6_0 = Current + + class V5_2 < V6_0 + end + + class V5_1 < V5_2 + def change_column(table_name, column_name, type, options = {}) + if adapter_name == "PostgreSQL" + clear_cache! + sql = connection.send(:change_column_sql, table_name, column_name, type, options) + execute "ALTER TABLE #{quote_table_name(table_name)} #{sql}" + change_column_default(table_name, column_name, options[:default]) if options.key?(:default) + change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) + else + super + end + end + + def create_table(table_name, options = {}) + if adapter_name == "Mysql2" + super(table_name, options: "ENGINE=InnoDB", **options) + else + super + end + end + end class V5_0 < V5_1 module TableDefinition + def primary_key(name, type = :primary_key, **options) + type = :integer if type == :primary_key + super + end + def references(*args, **options) super(*args, type: :integer, **options) end @@ -34,7 +67,7 @@ module ActiveRecord end end - # Since 5.1 Postgres adapter uses bigserial type for primary + # Since 5.1 PostgreSQL adapter uses bigserial type for primary # keys by default and MySQL uses bigint. This compat layer makes old migrations utilize # serial/int type instead -- the way it used to work before 5.1. unless options.key?(:id) @@ -42,11 +75,8 @@ module ActiveRecord end if block_given? - super(table_name, options) do |t| - class << t - prepend TableDefinition - end - yield t + super do |t| + yield compatible_table_definition(t) end else super @@ -55,21 +85,46 @@ module ActiveRecord def change_table(table_name, options = {}) if block_given? - super(table_name, options) do |t| - class << t - prepend TableDefinition - end - yield t + super do |t| + yield compatible_table_definition(t) end else super end end + def create_join_table(table_1, table_2, column_options: {}, **options) + column_options.reverse_merge!(type: :integer) + + if block_given? + super do |t| + yield compatible_table_definition(t) + end + else + super + end + end + + def add_column(table_name, column_name, type, options = {}) + if type == :primary_key + type = :integer + options[:primary_key] = true + end + super + end + def add_reference(table_name, ref_name, **options) super(table_name, ref_name, type: :integer, **options) end alias :add_belongs_to :add_reference + + private + def compatible_table_definition(t) + class << t + prepend TableDefinition + end + t + end end class V4_2 < V5_0 @@ -88,11 +143,8 @@ module ActiveRecord def create_table(table_name, options = {}) if block_given? - super(table_name, options) do |t| - class << t - prepend TableDefinition - end - yield t + super do |t| + yield compatible_table_definition(t) end else super @@ -101,11 +153,8 @@ module ActiveRecord def change_table(table_name, options = {}) if block_given? - super(table_name, options) do |t| - class << t - prepend TableDefinition - end - yield t + super do |t| + yield compatible_table_definition(t) end else super @@ -141,6 +190,12 @@ module ActiveRecord end private + def compatible_table_definition(t) + class << t + prepend TableDefinition + end + super + end def index_name_for_remove(table_name, options = {}) index_name = index_name(table_name, options) diff --git a/activerecord/lib/active_record/migration/join_table.rb b/activerecord/lib/active_record/migration/join_table.rb index 89789f00ea..9abb289bb0 100644 --- a/activerecord/lib/active_record/migration/join_table.rb +++ b/activerecord/lib/active_record/migration/join_table.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class Migration module JoinTable #:nodoc: diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 54216caaaf..694ff85fa1 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require "monitor" + module ActiveRecord module ModelSchema extend ActiveSupport::Concern @@ -83,19 +87,6 @@ module ActiveRecord # Sets the name of the internal metadata table. ## - # :singleton-method: protected_environments - # :call-seq: protected_environments - # - # The array of names of environments where destructive actions should be prohibited. By default, - # the value is <tt>["production"]</tt>. - - ## - # :singleton-method: protected_environments= - # :call-seq: protected_environments=(environments) - # - # Sets an array of names of environments where destructive actions should be prohibited. - - ## # :singleton-method: pluralize_table_names # :call-seq: pluralize_table_names # @@ -111,47 +102,22 @@ module ActiveRecord # If true, the default table name for a Product class will be "products". If false, it would just be "product". # See table_name for the full rules on table/class naming. This is true, by default. - ## - # :singleton-method: ignored_columns - # :call-seq: ignored_columns - # - # The list of columns names the model should ignore. Ignored columns won't have attribute - # accessors defined, and won't be referenced in SQL queries. - - ## - # :singleton-method: ignored_columns= - # :call-seq: ignored_columns=(columns) - # - # Sets the columns names the model should ignore. Ignored columns won't have attribute - # accessors defined, and won't be referenced in SQL queries. - included do mattr_accessor :primary_key_prefix_type, instance_writer: false - class_attribute :table_name_prefix, instance_writer: false - self.table_name_prefix = "" - - class_attribute :table_name_suffix, instance_writer: false - self.table_name_suffix = "" - - class_attribute :schema_migrations_table_name, instance_accessor: false - self.schema_migrations_table_name = "schema_migrations" - - class_attribute :internal_metadata_table_name, instance_accessor: false - self.internal_metadata_table_name = "ar_internal_metadata" + class_attribute :table_name_prefix, instance_writer: false, default: "" + class_attribute :table_name_suffix, instance_writer: false, default: "" + class_attribute :schema_migrations_table_name, instance_accessor: false, default: "schema_migrations" + class_attribute :internal_metadata_table_name, instance_accessor: false, default: "ar_internal_metadata" + class_attribute :pluralize_table_names, instance_writer: false, default: true - class_attribute :protected_environments, instance_accessor: false self.protected_environments = ["production"] - - class_attribute :pluralize_table_names, instance_writer: false - self.pluralize_table_names = true - - class_attribute :ignored_columns, instance_accessor: false - self.ignored_columns = [].freeze - self.inheritance_column = "type" + self.ignored_columns = [].freeze delegate :type_for_attribute, to: :class + + initialize_load_schema_monitor end # Derives the join table name for +first_table+ and +second_table+. The @@ -259,6 +225,21 @@ module ActiveRecord (parents.detect { |p| p.respond_to?(:table_name_suffix) } || self).table_name_suffix end + # The array of names of environments where destructive actions should be prohibited. By default, + # the value is <tt>["production"]</tt>. + def protected_environments + if defined?(@protected_environments) + @protected_environments + else + superclass.protected_environments + end + end + + # Sets an array of names of environments where destructive actions should be prohibited. + def protected_environments=(environments) + @protected_environments = environments.map(&:to_s) + end + # Defines the name of the table column which will store the class name on single-table # inheritance situations. # @@ -278,8 +259,24 @@ module ActiveRecord @explicit_inheritance_column = true end + # The list of columns names the model should ignore. Ignored columns won't have attribute + # accessors defined, and won't be referenced in SQL queries. + def ignored_columns + if defined?(@ignored_columns) + @ignored_columns + else + superclass.ignored_columns + end + end + + # Sets the columns names the model should ignore. Ignored columns won't have attribute + # accessors defined, and won't be referenced in SQL queries. + def ignored_columns=(columns) + @ignored_columns = columns.map(&:to_s) + end + def sequence_name - if base_class == self + if base_class? @sequence_name ||= reset_sequence_name else (@sequence_name ||= nil) || base_class.sequence_name @@ -328,11 +325,11 @@ module ActiveRecord end def attributes_builder # :nodoc: - @attributes_builder ||= AttributeSet::Builder.new(attribute_types, primary_key) do |name| - unless columns_hash.key?(name) - _default_attributes[name].dup - end + unless defined?(@attributes_builder) && @attributes_builder + defaults = _default_attributes.except(*(column_names - [primary_key])) + @attributes_builder = ActiveModel::AttributeSet::Builder.new(attribute_types, defaults) end + @attributes_builder end def columns_hash # :nodoc: @@ -351,7 +348,7 @@ module ActiveRecord end def yaml_encoder # :nodoc: - @yaml_encoder ||= AttributeSet::YAMLEncoder.new(attribute_types) + @yaml_encoder ||= ActiveModel::AttributeSet::YAMLEncoder.new(attribute_types) end # Returns the type of the attribute with the given name, after applying @@ -364,8 +361,9 @@ module ActiveRecord # it). # # +attr_name+ The name of the attribute to retrieve the type for. Must be - # a string + # a string or a symbol. def type_for_attribute(attr_name, &block) + attr_name = attr_name.to_s if block attribute_types.fetch(attr_name, &block) else @@ -377,11 +375,12 @@ module ActiveRecord # default values when instantiating the Active Record object for this table. def column_defaults load_schema - _default_attributes.to_hash + @column_defaults ||= _default_attributes.to_hash end def _default_attributes # :nodoc: - @default_attributes ||= AttributeSet.new({}) + load_schema + @default_attributes ||= ActiveModel::AttributeSet.new({}) end # Returns an array of column names as strings. @@ -428,22 +427,38 @@ module ActiveRecord # end def reset_column_information connection.clear_cache! - undefine_attribute_methods + ([self] + descendants).each(&:undefine_attribute_methods) connection.schema_cache.clear_data_source_cache!(table_name) reload_schema_from_cache initialize_find_by_cache end + protected + + def initialize_load_schema_monitor + @load_schema_monitor = Monitor.new + end + private + def inherited(child_class) + super + child_class.initialize_load_schema_monitor + end + def schema_loaded? - defined?(@columns_hash) && @columns_hash + defined?(@schema_loaded) && @schema_loaded end def load_schema - unless schema_loaded? + return if schema_loaded? + @load_schema_monitor.synchronize do + return if defined?(@columns_hash) && @columns_hash + load_schema! + + @schema_loaded = true end end @@ -460,16 +475,17 @@ module ActiveRecord end def reload_schema_from_cache - @arel_engine = nil @arel_table = nil @column_names = nil @attribute_types = nil @content_columns = nil @default_attributes = nil + @column_defaults = nil @inheritance_column = nil unless defined?(@explicit_inheritance_column) && @explicit_inheritance_column @attributes_builder = nil @columns = nil @columns_hash = nil + @schema_loaded = false @attribute_names = nil @yaml_encoder = nil direct_descendants.each do |descendant| @@ -485,8 +501,7 @@ module ActiveRecord # Computes and returns a table name according to default conventions. def compute_table_name - base = base_class - if self == base + if base_class? # Nested classes are prefixed with singular parent table name. if parent < Base && !parent.abstract_class? contained = parent.table_name @@ -497,7 +512,7 @@ module ActiveRecord "#{full_table_name_prefix}#{contained}#{undecorated_table_name(name)}#{full_table_name_suffix}" else # STI subclasses always use their superclass' table. - base.table_name + base_class.table_name end end end diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb index 01ecd79b8f..50767ee93f 100644 --- a/activerecord/lib/active_record/nested_attributes.rb +++ b/activerecord/lib/active_record/nested_attributes.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "active_support/core_ext/hash/except" +require "active_support/core_ext/module/redefine_method" require "active_support/core_ext/object/try" require "active_support/core_ext/hash/indifferent_access" @@ -10,8 +13,7 @@ module ActiveRecord extend ActiveSupport::Concern included do - class_attribute :nested_attributes_options, instance_writer: false - self.nested_attributes_options = {} + class_attribute :nested_attributes_options, instance_writer: false, default: {} end # = Active Record Nested Attributes @@ -61,6 +63,18 @@ module ActiveRecord # member.update params[:member] # member.avatar.icon # => 'sad' # + # If you want to update the current avatar without providing the id, you must add <tt>:update_only</tt> option. + # + # class Member < ActiveRecord::Base + # has_one :avatar + # accepts_nested_attributes_for :avatar, update_only: true + # end + # + # params = { member: { avatar_attributes: { icon: 'sad' } } } + # member.update params[:member] + # member.avatar.id # => 2 + # member.avatar.icon # => 'sad' + # # By default you will only be able to set and update attributes on the # associated model. If you want to destroy the associated model through the # attributes hash, you have to enable it first using the @@ -354,9 +368,7 @@ module ActiveRecord # associations are just regular associations. def generate_association_writer(association_name, type) generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1 - if method_defined?(:#{association_name}_attributes=) - remove_method(:#{association_name}_attributes=) - end + silence_redefinition_of_method :#{association_name}_attributes= def #{association_name}_attributes=(attributes) assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes) end @@ -458,7 +470,7 @@ module ActiveRecord end unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array) - raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})" + raise ArgumentError, "Hash or Array expected for attribute `#{association_name}`, got #{attributes_collection.class.name} (#{attributes_collection.inspect})" end check_record_limit!(options[:limit], attributes_collection) @@ -489,7 +501,7 @@ module ActiveRecord if attributes["id"].blank? unless reject_new_record?(association_name, attributes) - association.build(attributes.except(*UNASSIGNABLE_KEYS)) + association.reader.build(attributes.except(*UNASSIGNABLE_KEYS)) end elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes["id"].to_s } unless call_reject_if(association_name, attributes) diff --git a/activerecord/lib/active_record/no_touching.rb b/activerecord/lib/active_record/no_touching.rb index 4059020e25..697076bdae 100644 --- a/activerecord/lib/active_record/no_touching.rb +++ b/activerecord/lib/active_record/no_touching.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record No Touching module NoTouching extend ActiveSupport::Concern module ClassMethods - # Lets you selectively disable calls to `touch` for the + # Lets you selectively disable calls to +touch+ for the # duration of a block. # # ==== Examples @@ -41,6 +43,13 @@ module ActiveRecord end end + # Returns +true+ if the class has +no_touching+ set, +false+ otherwise. + # + # Project.no_touching do + # Project.first.no_touching? # true + # Message.first.no_touching? # false + # end + # def no_touching? NoTouching.applied_to?(self.class) end diff --git a/activerecord/lib/active_record/null_relation.rb b/activerecord/lib/active_record/null_relation.rb index 2bb7ed6d5e..cf0de0fdeb 100644 --- a/activerecord/lib/active_record/null_relation.rb +++ b/activerecord/lib/active_record/null_relation.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + module ActiveRecord module NullRelation # :nodoc: def pluck(*column_names) [] end - def delete_all(_conditions = nil) + def delete_all 0 end diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 7ceb7d1a55..6eb2bfb452 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record \Persistence module Persistence @@ -65,11 +67,151 @@ module ActiveRecord # how this "single-table" inheritance mapping is implemented. def instantiate(attributes, column_types = {}, &block) klass = discriminate_class_for_record(attributes) - attributes = klass.attributes_builder.build_from_database(attributes, column_types) - klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block) + instantiate_instance_of(klass, attributes, column_types, &block) + end + + # Updates an object (or multiple objects) and saves it to the database, if validations pass. + # The resulting object is returned whether the object was saved successfully to the database or not. + # + # ==== Parameters + # + # * +id+ - This should be the id or an array of ids to be updated. + # * +attributes+ - This should be a hash of attributes or an array of hashes. + # + # ==== Examples + # + # # Updates one record + # Person.update(15, user_name: "Samuel", group: "expert") + # + # # Updates multiple records + # people = { 1 => { "first_name" => "David" }, 2 => { "first_name" => "Jeremy" } } + # Person.update(people.keys, people.values) + # + # # Updates multiple records from the result of a relation + # people = Person.where(group: "expert") + # people.update(group: "masters") + # + # Note: Updating a large number of records will run an UPDATE + # query for each record, which may cause a performance issue. + # When running callbacks is not needed for each record update, + # it is preferred to use {update_all}[rdoc-ref:Relation#update_all] + # for updating all records in a single query. + def update(id, attributes) + if id.is_a?(Array) + id.map { |one_id| find(one_id) }.each_with_index { |object, idx| + object.update(attributes[idx]) + } + else + if ActiveRecord::Base === id + raise ArgumentError, + "You are passing an instance of ActiveRecord::Base to `update`. " \ + "Please pass the id of the object by calling `.id`." + end + object = find(id) + object.update(attributes) + object + end + end + + # Destroy an object (or multiple objects) that has the given id. The object is instantiated first, + # therefore all callbacks and filters are fired off before the object is deleted. This method is + # less efficient than #delete but allows cleanup methods and other actions to be run. + # + # This essentially finds the object (or multiple objects) with the given id, creates a new object + # from the attributes, and then calls destroy on it. + # + # ==== Parameters + # + # * +id+ - This should be the id or an array of ids to be destroyed. + # + # ==== Examples + # + # # Destroy a single object + # Todo.destroy(1) + # + # # Destroy multiple objects + # todos = [1,2,3] + # Todo.destroy(todos) + def destroy(id) + if id.is_a?(Array) + find(id).each(&:destroy) + else + find(id).destroy + end + end + + # Deletes the row with a primary key matching the +id+ argument, using a + # SQL +DELETE+ statement, and returns the number of rows deleted. Active + # Record objects are not instantiated, so the object's callbacks are not + # executed, including any <tt>:dependent</tt> association options. + # + # You can delete multiple rows at once by passing an Array of <tt>id</tt>s. + # + # Note: Although it is often much faster than the alternative, #destroy, + # skipping callbacks might bypass business logic in your application + # that ensures referential integrity or performs other essential jobs. + # + # ==== Examples + # + # # Delete a single row + # Todo.delete(1) + # + # # Delete multiple rows + # Todo.delete([2,3,4]) + def delete(id_or_array) + where(primary_key => id_or_array).delete_all + end + + def _insert_record(values) # :nodoc: + primary_key_value = nil + + if primary_key && Hash === values + primary_key_value = values[primary_key] + + if !primary_key_value && prefetch_primary_key? + primary_key_value = next_sequence_value + values[primary_key] = primary_key_value + end + end + + if values.empty? + im = arel_table.compile_insert(connection.empty_insert_statement_value(primary_key)) + im.into arel_table + else + im = arel_table.compile_insert(_substitute_values(values)) + end + + connection.insert(im, "#{self} Create", primary_key || false, primary_key_value) + end + + def _update_record(values, constraints) # :nodoc: + constraints = _substitute_values(constraints).map { |attr, bind| attr.eq(bind) } + + um = arel_table.where( + constraints.reduce(&:and) + ).compile_update(_substitute_values(values), primary_key) + + connection.update(um, "#{self} Update") + end + + def _delete_record(constraints) # :nodoc: + constraints = _substitute_values(constraints).map { |attr, bind| attr.eq(bind) } + + dm = Arel::DeleteManager.new + dm.from(arel_table) + dm.wheres = constraints + + connection.delete(dm, "#{self} Destroy") end private + # Given a class, an attributes hash, +instantiate_instance_of+ returns a + # new instance of the class. Accepts only keys as strings. + def instantiate_instance_of(klass, attributes, column_types = {}, &block) + attributes = klass.attributes_builder.build_from_database(attributes, column_types) + klass.allocate.init_from_db(attributes, &block) + end + # Called by +instantiate+ to decide which class to use for a new # record instance. # @@ -78,6 +220,14 @@ module ActiveRecord def discriminate_class_for_record(record) self end + + def _substitute_values(values) + values.map do |name, value| + attr = arel_attribute(name) + bind = predicate_builder.build_bind_attribute(name, value) + [attr, bind] + end + end end # Returns true if this object hasn't been saved yet -- that is, a record @@ -100,6 +250,10 @@ module ActiveRecord !(@new_record || @destroyed) end + ## + # :call-seq: + # save(*args) + # # Saves the model. # # If the model is new, a record gets created in the database, otherwise @@ -121,12 +275,16 @@ module ActiveRecord # # Attributes marked as readonly are silently ignored if the record is # being updated. - def save(*args) - create_or_update(*args) + def save(*args, &block) + create_or_update(*args, &block) rescue ActiveRecord::RecordInvalid false end + ## + # :call-seq: + # save!(*args) + # # Saves the model. # # If the model is new, a record gets created in the database, otherwise @@ -150,8 +308,8 @@ module ActiveRecord # being updated. # # Unless an error is raised, returns true. - def save!(*args) - create_or_update(*args) || raise(RecordNotSaved.new("Failed to save the record", self)) + def save!(*args, &block) + create_or_update(*args, &block) || raise(RecordNotSaved.new("Failed to save the record", self)) end # Deletes the record in the database and freezes this instance to @@ -167,7 +325,7 @@ module ActiveRecord # callbacks or any <tt>:dependent</tt> association # options, use <tt>#destroy</tt>. def delete - self.class.delete(id) if persisted? + _delete_row if persisted? @destroyed = true freeze end @@ -216,9 +374,10 @@ module ActiveRecord # Any change to the attributes on either instance will affect both instances. # If you want to change the sti column as well, use #becomes! instead. def becomes(klass) - became = klass.new + became = klass.allocate + became.send(:initialize) became.instance_variable_set("@attributes", @attributes) - became.instance_variable_set("@mutation_tracker", @mutation_tracker) if defined?(@mutation_tracker) + 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?) @@ -259,11 +418,7 @@ module ActiveRecord verify_readonly_attribute(name) public_send("#{name}=", value) - if has_changes_to_save? - save(validate: false) - else - true - end + save(validate: false) end # Updates the attributes of the model from the passed-in hash and saves the @@ -279,6 +434,7 @@ module ActiveRecord end alias update_attributes update + deprecate :update_attributes # Updates its receiver just like #update but calls #save! instead # of +save+, so an exception is raised if the record is invalid and saving will fail. @@ -292,6 +448,7 @@ module ActiveRecord end alias update_attributes! update! + deprecate :update_attributes! # Equivalent to <code>update_columns(name => value)</code>. def update_column(name, value) @@ -322,13 +479,16 @@ module ActiveRecord verify_readonly_attribute(key.to_s) end - updated_count = self.class.unscoped.where(self.class.primary_key => id).update_all(attributes) + affected_rows = self.class._update_record( + attributes, + self.class.primary_key => id_in_database + ) attributes.each do |k, v| - raw_write_attribute(k, v) + write_attribute_without_type_cast(k, v) end - updated_count == 1 + affected_rows == 1 end # Initializes +attribute+ to zero if +nil+ and adds the value passed as +by+ (default is 1). @@ -343,7 +503,7 @@ module ActiveRecord # Wrapper around #increment that writes the update to the database. # Only +attribute+ is updated; the record itself is not saved. # This means that any other modified attributes will still be dirty. - # Validations and callbacks are skipped. Supports the `touch` option from + # Validations and callbacks are skipped. Supports the +touch+ option from # +update_counters+, see that for more. # Returns +self+. def increment!(attribute, by = 1, touch: nil) @@ -364,7 +524,7 @@ module ActiveRecord # Wrapper around #decrement that writes the update to the database. # Only +attribute+ is updated; the record itself is not saved. # This means that any other modified attributes will still be dirty. - # Validations and callbacks are skipped. Supports the `touch` option from + # Validations and callbacks are skipped. Supports the +touch+ option from # +update_counters+, see that for more. # Returns +self+. def decrement!(attribute, by = 1, touch: nil) @@ -501,36 +661,12 @@ module ActiveRecord MSG end - time ||= current_time_from_proper_timezone - attributes = timestamp_attributes_for_update_in_model - attributes.concat(names) - - unless attributes.empty? - changes = {} - - attributes.each do |column| - column = column.to_s - changes[column] = write_attribute(column, time) - end - - primary_key = self.class.primary_key - scope = self.class.unscoped.where(primary_key => _read_attribute(primary_key)) - - if locking_enabled? - locking_column = self.class.locking_column - scope = scope.where(locking_column => _read_attribute(locking_column)) - changes[locking_column] = increment_lock - end - - clear_attribute_changes(changes.keys) - result = scope.update_all(changes) == 1 + attribute_names = timestamp_attributes_for_update_in_model + attribute_names |= names.map(&:to_s) - if !result && locking_enabled? - raise ActiveRecord::StaleObjectError.new(self, "touch") - end - - @_trigger_update_callback = result - result + unless attribute_names.empty? + affected_rows = _touch_row(attribute_names, time) + @_trigger_update_callback = affected_rows == 1 else true end @@ -543,42 +679,71 @@ module ActiveRecord end def destroy_row - relation_for_destroy.delete_all + _delete_row end - def relation_for_destroy - self.class.unscoped.where(self.class.primary_key => id) + def _delete_row + self.class._delete_record(self.class.primary_key => id_in_database) end - def create_or_update(*args) + 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) + end + + _update_row(attribute_names, "touch") + end + + def _update_row(attribute_names, attempted_action = "update") + self.class._update_record( + attributes_with_values(attribute_names), + self.class.primary_key => id_in_database + ) + end + + def create_or_update(*args, &block) _raise_readonly_record_error if readonly? - result = new_record? ? _create_record : _update_record(*args) + return false if destroyed? + result = new_record? ? _create_record(&block) : _update_record(*args, &block) result != false end # Updates the associated record with values matching those of the instance attributes. # Returns the number of affected rows. def _update_record(attribute_names = self.attribute_names) - attributes_values = arel_attributes_with_values_for_update(attribute_names) - if attributes_values.empty? - rows_affected = 0 + attribute_names = attributes_for_update(attribute_names) + + if attribute_names.empty? + affected_rows = 0 @_trigger_update_callback = true else - rows_affected = self.class.unscoped._update_record attributes_values, id, id_in_database - @_trigger_update_callback = rows_affected > 0 + affected_rows = _update_row(attribute_names) + @_trigger_update_callback = affected_rows == 1 end - rows_affected + + yield(self) if block_given? + + affected_rows end # Creates a record with values matching those of the instance attributes # and returns its id. def _create_record(attribute_names = self.attribute_names) - attributes_values = arel_attributes_with_values_for_create(attribute_names) + attribute_names = attributes_for_create(attribute_names) + + new_id = self.class._insert_record( + attributes_with_values(attribute_names) + ) - new_id = self.class.unscoped.insert attributes_values self.id ||= new_id if self.class.primary_key @new_record = false + + yield(self) if block_given? + id end diff --git a/activerecord/lib/active_record/query_cache.rb b/activerecord/lib/active_record/query_cache.rb index ec246e97bc..28194c7c46 100644 --- a/activerecord/lib/active_record/query_cache.rb +++ b/activerecord/lib/active_record/query_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record Query Cache class QueryCache @@ -5,7 +7,7 @@ module ActiveRecord # Enable the query cache within the block if Active Record is configured. # If it's not, it will execute the given block. def cache(&block) - if connected? + if connected? || !configurations.empty? connection.cache(&block) else yield @@ -15,7 +17,7 @@ module ActiveRecord # Disable the query cache within the block if Active Record is configured. # If it's not, it will execute the given block. def uncached(&block) - if connected? + if connected? || !configurations.empty? connection.uncached(&block) else yield @@ -24,16 +26,12 @@ module ActiveRecord end def self.run - caching_pool = ActiveRecord::Base.connection_pool - caching_was_enabled = caching_pool.query_cache_enabled - - caching_pool.enable_query_cache! - - [caching_pool, caching_was_enabled] + ActiveRecord::Base.connection_handler.connection_pool_list. + reject { |p| p.query_cache_enabled }.each { |p| p.enable_query_cache! } end - def self.complete((caching_pool, caching_was_enabled)) - caching_pool.disable_query_cache! unless caching_was_enabled + def self.complete(pools) + pools.each { |pool| pool.disable_query_cache! } ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool| pool.release_connection if pool.active_connection? && !pool.connection.transaction_open? diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 36689f6559..c84f3d0fbb 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -1,17 +1,19 @@ +# frozen_string_literal: true + module ActiveRecord module Querying delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, to: :all delegate :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, to: :all delegate :first_or_create, :first_or_create!, :first_or_initialize, to: :all - delegate :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, to: :all + delegate :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, to: :all delegate :find_by, :find_by!, to: :all - delegate :destroy, :destroy_all, :delete, :delete_all, :update, :update_all, to: :all + delegate :destroy_all, :delete_all, :update_all, to: :all delegate :find_each, :find_in_batches, :in_batches, to: :all delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, - :having, :create_with, :uniq, :distinct, :references, :none, :unscope, :merge, to: :all + :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, + :having, :create_with, :distinct, :references, :none, :unscope, :merge, to: :all delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all - delegate :pluck, :ids, to: :all + delegate :pluck, :pick, :ids, to: :all # Executes a custom SQL query against your database and returns all the results. The results will # be returned as an array with columns requested encapsulated as attributes of the model you call @@ -47,7 +49,12 @@ module ActiveRecord } message_bus.instrument("instantiation.active_record", payload) do - result_set.map { |record| instantiate(record, column_types, &block) } + if result_set.includes_column?(inheritance_column) + result_set.map { |record| instantiate(record, column_types, &block) } + else + # Instantiate a homogeneous set + result_set.map { |record| instantiate_instance_of(self, record, column_types, &block) } + end end end diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 0276d41494..47351588d3 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record" require "rails" require "active_model/railtie" @@ -26,6 +28,9 @@ module ActiveRecord config.active_record.use_schema_cache_dump = true config.active_record.maintain_test_schema = true + config.active_record.sqlite3 = ActiveSupport::OrderedOptions.new + config.active_record.sqlite3.represent_boolean_as_integer = nil + config.eager_load_namespaces << ActiveRecord rake_tasks do @@ -54,6 +59,7 @@ module ActiveRecord console = ActiveSupport::Logger.new(STDERR) Rails.logger.extend ActiveSupport::Logger.broadcast console end + ActiveRecord::Base.verbose_query_logs = false end runner do @@ -71,6 +77,10 @@ module ActiveRecord ActiveSupport.on_load(:active_record) { self.logger ||= ::Rails.logger } end + initializer "active_record.backtrace_cleaner" do + ActiveSupport.on_load(:active_record) { LogSubscriber.backtrace_cleaner = ::Rails.backtrace_cleaner } + end + initializer "active_record.migration_error" do if config.active_record.delete(:migration_error) == :page_load config.app_middleware.insert_after ::ActionDispatch::Callbacks, @@ -85,12 +95,16 @@ module ActiveRecord filename = File.join(app.config.paths["db"].first, "schema_cache.yml") if File.file?(filename) + current_version = ActiveRecord::Migrator.current_version + + next if current_version.nil? + cache = YAML.load(File.read(filename)) - if cache.version == ActiveRecord::Migrator.current_version + if cache.version == current_version connection.schema_cache = cache connection_pool.schema_cache = cache.dup else - warn "Ignoring db/schema_cache.yml because it has expired. The current schema version is #{ActiveRecord::Migrator.current_version}, but the one in the cache is #{cache.version}." + warn "Ignoring db/schema_cache.yml because it has expired. The current schema version is #{current_version}, but the one in the cache is #{cache.version}." end end end @@ -108,7 +122,9 @@ module ActiveRecord initializer "active_record.set_configs" do |app| ActiveSupport.on_load(:active_record) do - app.config.active_record.each do |k, v| + configs = app.config.active_record.dup + configs.delete(:sqlite3) + configs.each do |k, v| send "#{k}=", v end end @@ -129,8 +145,8 @@ Oops - You have a database configured, but it doesn't exist yet! Here's how to get started: 1. Configure your database in config/database.yml. - 2. Run `bin/rails db:create` to create the database. - 3. Run `bin/rails db:setup` to load your database schema. + 2. Run `rails db:create` to create the database. + 3. Run `rails db:setup` to load your database schema. end_warning raise end @@ -145,6 +161,13 @@ end_warning end end + initializer "active_record.collection_cache_association_loading" do + require "active_record/railties/collection_cache_association_loading" + ActiveSupport.on_load(:action_view) do + ActionView::PartialRenderer.prepend(ActiveRecord::Railties::CollectionCacheAssociationLoading) + end + end + initializer "active_record.set_reloader_hooks" do ActiveSupport.on_load(:active_record) do ActiveSupport::Reloader.before_class_unload do @@ -166,5 +189,57 @@ end_warning path = app.paths["db"].first config.watchable_files.concat ["#{path}/schema.rb", "#{path}/structure.sql"] end + + initializer "active_record.clear_active_connections" do + config.after_initialize do + ActiveSupport.on_load(:active_record) do + # Ideally the application doesn't connect to the database during boot, + # but sometimes it does. In case it did, we want to empty out the + # connection pools so that a non-database-using process (e.g. a master + # process in a forking server model) doesn't retain a needless + # connection. If it was needed, the incremental cost of reestablishing + # this connection is trivial: the rest of the pool would need to be + # populated anyway. + + clear_active_connections! + flush_idle_connections! + end + end + end + + initializer "active_record.check_represent_sqlite3_boolean_as_integer" do + config.after_initialize do + ActiveSupport.on_load(:active_record_sqlite3adapter) do + represent_boolean_as_integer = Rails.application.config.active_record.sqlite3.delete(:represent_boolean_as_integer) + unless represent_boolean_as_integer.nil? + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = represent_boolean_as_integer + end + + unless ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer + ActiveSupport::Deprecation.warn <<-MSG +Leaving `ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer` +set to false is deprecated. SQLite databases have used 't' and 'f' to serialize +boolean values and must have old data converted to 1 and 0 (its native boolean +serialization) before setting this flag to true. Conversion can be accomplished +by setting up a rake task which runs + + ExampleModel.where("boolean_column = 't'").update_all(boolean_column: 1) + ExampleModel.where("boolean_column = 'f'").update_all(boolean_column: 0) + +for all models and all boolean columns, after which the flag must be set to +true by adding the following to your application.rb file: + + Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true +MSG + end + end + end + end + + initializer "active_record.set_filter_attributes" do + ActiveSupport.on_load(:active_record) do + self.filter_attributes += Rails.application.config.filter_parameters + end + end end end diff --git a/activerecord/lib/active_record/railties/collection_cache_association_loading.rb b/activerecord/lib/active_record/railties/collection_cache_association_loading.rb new file mode 100644 index 0000000000..b5129e4239 --- /dev/null +++ b/activerecord/lib/active_record/railties/collection_cache_association_loading.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActiveRecord + module Railties # :nodoc: + module CollectionCacheAssociationLoading #:nodoc: + def setup(context, options, block) + @relation = relation_from_options(options) + + super + end + + def relation_from_options(cached: nil, partial: nil, collection: nil, **_) + return unless cached + + relation = partial if partial.is_a?(ActiveRecord::Relation) + relation ||= collection if collection.is_a?(ActiveRecord::Relation) + + if relation && !relation.loaded? + relation.skip_preloading! + end + end + + def collection_without_template + @relation.preload_associations(@collection) if @relation + super + end + + def collection_with_template + @relation.preload_associations(@collection) if @relation + super + end + end + end +end diff --git a/activerecord/lib/active_record/railties/console_sandbox.rb b/activerecord/lib/active_record/railties/console_sandbox.rb index 604a220303..8917638a5d 100644 --- a/activerecord/lib/active_record/railties/console_sandbox.rb +++ b/activerecord/lib/active_record/railties/console_sandbox.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ActiveRecord::Base.connection.begin_transaction(joinable: false) at_exit do diff --git a/activerecord/lib/active_record/railties/controller_runtime.rb b/activerecord/lib/active_record/railties/controller_runtime.rb index 8658188623..309441a057 100644 --- a/activerecord/lib/active_record/railties/controller_runtime.rb +++ b/activerecord/lib/active_record/railties/controller_runtime.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/module/attr_internal" require "active_record/log_subscriber" @@ -6,49 +8,44 @@ module ActiveRecord module ControllerRuntime #:nodoc: extend ActiveSupport::Concern - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_internal :db_runtime - - private - - def process_action(action, *args) - # We also need to reset the runtime before each action - # because of queries in middleware or in cases we are streaming - # and it won't be cleaned up by the method below. - ActiveRecord::LogSubscriber.reset_runtime - super + module ClassMethods # :nodoc: + def log_process_action(payload) + messages, db_runtime = super, payload[:db_runtime] + messages << ("ActiveRecord: %.1fms" % db_runtime.to_f) if db_runtime + messages + end end - def cleanup_view_runtime - if logger && logger.info? && ActiveRecord::Base.connected? - db_rt_before_render = ActiveRecord::LogSubscriber.reset_runtime - self.db_runtime = (db_runtime || 0) + db_rt_before_render - runtime = super - db_rt_after_render = ActiveRecord::LogSubscriber.reset_runtime - self.db_runtime += db_rt_after_render - runtime - db_rt_after_render - else + private + attr_internal :db_runtime + + def process_action(action, *args) + # We also need to reset the runtime before each action + # because of queries in middleware or in cases we are streaming + # and it won't be cleaned up by the method below. + ActiveRecord::LogSubscriber.reset_runtime super end - end - def append_info_to_payload(payload) - super - if ActiveRecord::Base.connected? - payload[:db_runtime] = (db_runtime || 0) + ActiveRecord::LogSubscriber.reset_runtime + def cleanup_view_runtime + if logger && logger.info? && ActiveRecord::Base.connected? + db_rt_before_render = ActiveRecord::LogSubscriber.reset_runtime + self.db_runtime = (db_runtime || 0) + db_rt_before_render + runtime = super + db_rt_after_render = ActiveRecord::LogSubscriber.reset_runtime + self.db_runtime += db_rt_after_render + runtime - db_rt_after_render + else + super + end end - end - module ClassMethods # :nodoc: - def log_process_action(payload) - messages, db_runtime = super, payload[:db_runtime] - messages << ("ActiveRecord: %.1fms" % db_runtime.to_f) if db_runtime - messages + def append_info_to_payload(payload) + super + if ActiveRecord::Base.connected? + payload[:db_runtime] = (db_runtime || 0) + ActiveRecord::LogSubscriber.reset_runtime + end end - end end end end diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index fabd326649..be5ef350a9 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -1,17 +1,19 @@ +# frozen_string_literal: true + require "active_record" db_namespace = namespace :db do desc "Set the environment value for the database" - task "environment:set" => [:environment, :load_config] do + task "environment:set" => :load_config do ActiveRecord::InternalMetadata.create_table - ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment + ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Base.connection.migration_context.current_environment end - task check_protected_environments: [:environment, :load_config] do + task check_protected_environments: :load_config do ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! end - task :load_config do + task load_config: :environment do ActiveRecord::Base.configurations = ActiveRecord::Tasks::DatabaseTasks.database_configuration || {} ActiveRecord::Migrator.migrations_paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths end @@ -20,6 +22,14 @@ db_namespace = namespace :db do task all: :load_config do ActiveRecord::Tasks::DatabaseTasks.create_all end + + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + desc "Create #{spec_name} database for current environment" + task spec_name => :load_config do + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) + ActiveRecord::Tasks::DatabaseTasks.create(db_config.config) + end + end end desc "Creates the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:create:all to create all databases in the config). Without RAILS_ENV or when RAILS_ENV is development, it defaults to creating the development and test databases." @@ -31,6 +41,14 @@ db_namespace = namespace :db do task all: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.drop_all end + + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + desc "Drop #{spec_name} database for current environment" + task spec_name => [:load_config, :check_protected_environments] do + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) + ActiveRecord::Tasks::DatabaseTasks.drop(db_config.config) + end + end end desc "Drops the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:drop:all to drop all databases in the config). Without RAILS_ENV or when RAILS_ENV is development, it defaults to dropping the development and test databases." @@ -54,8 +72,11 @@ db_namespace = namespace :db do end desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)." - task migrate: [:environment, :load_config] do - ActiveRecord::Tasks::DatabaseTasks.migrate + task migrate: :load_config do + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.migrate + end db_namespace["_dump"].invoke end @@ -75,8 +96,19 @@ db_namespace = namespace :db do end namespace :migrate do + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + desc "Migrate #{spec_name} database for current environment" + task spec_name => :load_config do + 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.migrate + end + end + # desc 'Rollbacks the database one migration and re migrate up (options: STEP=x, VERSION=x).' - task redo: [:environment, :load_config] do + task redo: :load_config do + raise "Empty VERSION provided" if ENV["VERSION"] && ENV["VERSION"].empty? + if ENV["VERSION"] db_namespace["migrate:down"].invoke db_namespace["migrate:up"].invoke @@ -90,48 +122,42 @@ db_namespace = namespace :db do task reset: ["db:drop", "db:create", "db:migrate"] # desc 'Runs the "up" for a given migration VERSION.' - task up: [:environment, :load_config] do - version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil - raise "VERSION is required" unless version - ActiveRecord::Migrator.run(:up, ActiveRecord::Tasks::DatabaseTasks.migrations_paths, version) + task up: :load_config do + raise "VERSION is required" if !ENV["VERSION"] || ENV["VERSION"].empty? + + ActiveRecord::Tasks::DatabaseTasks.check_target_version + + ActiveRecord::Base.connection.migration_context.run( + :up, + ActiveRecord::Tasks::DatabaseTasks.target_version + ) db_namespace["_dump"].invoke end # desc 'Runs the "down" for a given migration VERSION.' - task down: [:environment, :load_config] do - version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil - raise "VERSION is required - To go down one migration, run db:rollback" unless version - ActiveRecord::Migrator.run(:down, ActiveRecord::Tasks::DatabaseTasks.migrations_paths, version) + task down: :load_config do + raise "VERSION is required - To go down one migration, use db:rollback" if !ENV["VERSION"] || ENV["VERSION"].empty? + + ActiveRecord::Tasks::DatabaseTasks.check_target_version + + ActiveRecord::Base.connection.migration_context.run( + :down, + ActiveRecord::Tasks::DatabaseTasks.target_version + ) db_namespace["_dump"].invoke end desc "Display status of migrations" - task status: [:environment, :load_config] do + task status: :load_config do unless ActiveRecord::SchemaMigration.table_exists? abort "Schema migrations table does not exist yet." end - db_list = ActiveRecord::SchemaMigration.normalized_versions - - file_list = - ActiveRecord::Tasks::DatabaseTasks.migrations_paths.flat_map do |path| - Dir.foreach(path).map do |file| - next unless ActiveRecord::Migrator.match_to_migration_filename?(file) - - version, name, scope = ActiveRecord::Migrator.parse_migration_filename(file) - version = ActiveRecord::SchemaMigration.normalize_migration_number(version) - status = db_list.delete(version) ? "up" : "down" - [status, version, (name + scope).humanize] - end.compact - end - db_list.map! do |version| - ["up", version, "********** NO FILE **********"] - end # output puts "\ndatabase: #{ActiveRecord::Base.connection_config[:database]}\n\n" puts "#{'Status'.center(8)} #{'Migration ID'.ljust(14)} Migration Name" puts "-" * 50 - (db_list + file_list).sort_by { |_, version, _| version }.each do |status, version, name| + ActiveRecord::Base.connection.migration_context.migrations_status.each do |status, version, name| puts "#{status.center(8)} #{version.ljust(14)} #{name}" end puts @@ -139,16 +165,16 @@ db_namespace = namespace :db do end desc "Rolls the schema back to the previous version (specify steps w/ STEP=n)." - task rollback: [:environment, :load_config] do + task rollback: :load_config do step = ENV["STEP"] ? ENV["STEP"].to_i : 1 - ActiveRecord::Migrator.rollback(ActiveRecord::Tasks::DatabaseTasks.migrations_paths, step) + ActiveRecord::Base.connection.migration_context.rollback(step) db_namespace["_dump"].invoke end # desc 'Pushes the schema to the next version (specify steps w/ STEP=n).' - task forward: [:environment, :load_config] do + task forward: :load_config do step = ENV["STEP"] ? ENV["STEP"].to_i : 1 - ActiveRecord::Migrator.forward(ActiveRecord::Tasks::DatabaseTasks.migrations_paths, step) + ActiveRecord::Base.connection.migration_context.forward(step) db_namespace["_dump"].invoke end @@ -156,12 +182,12 @@ db_namespace = namespace :db do task reset: [ "db:drop", "db:setup" ] # desc "Retrieves the charset for the current environment's database" - task charset: [:environment, :load_config] do + task charset: :load_config do puts ActiveRecord::Tasks::DatabaseTasks.charset_current end # desc "Retrieves the collation for the current environment's database" - task collation: [:environment, :load_config] do + task collation: :load_config do begin puts ActiveRecord::Tasks::DatabaseTasks.collation_current rescue NoMethodError @@ -170,13 +196,13 @@ db_namespace = namespace :db do end desc "Retrieves the current schema version number" - task version: [:environment, :load_config] do - puts "Current version: #{ActiveRecord::Migrator.current_version}" + task version: :load_config do + puts "Current version: #{ActiveRecord::Base.connection.migration_context.current_version}" end # desc "Raises an error if there are pending migrations" - task abort_if_pending_migrations: [:environment, :load_config] do - pending_migrations = ActiveRecord::Migrator.open(ActiveRecord::Tasks::DatabaseTasks.migrations_paths).pending_migrations + task abort_if_pending_migrations: :load_config do + pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations if pending_migrations.any? puts "You have #{pending_migrations.size} pending #{pending_migrations.size > 1 ? 'migrations:' : 'migration:'}" @@ -191,14 +217,14 @@ db_namespace = namespace :db do task setup: ["db:schema:load_if_ruby", "db:structure:load_if_sql", :seed] desc "Loads the seed data from db/seeds.rb" - task :seed do + task seed: :load_config do db_namespace["abort_if_pending_migrations"].invoke ActiveRecord::Tasks::DatabaseTasks.load_seed end namespace :fixtures do desc "Loads fixtures into the current environment's database. Load specific fixtures using FIXTURES=x,y. Load from subdirectory in test/fixtures using FIXTURES_DIR=z. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." - task load: [:environment, :load_config] do + task load: :load_config do require "active_record/fixtures" base_dir = ActiveRecord::Tasks::DatabaseTasks.fixtures_path @@ -220,7 +246,7 @@ db_namespace = namespace :db do end # desc "Search for a fixture given a LABEL or ID. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." - task identify: [:environment, :load_config] do + task identify: :load_config do require "active_record/fixtures" label, id = ENV["LABEL"], ENV["ID"] @@ -231,7 +257,7 @@ db_namespace = namespace :db do base_dir = ActiveRecord::Tasks::DatabaseTasks.fixtures_path Dir["#{base_dir}/**/*.yml"].each do |file| - if data = YAML::load(ERB.new(IO.read(file)).result) + if data = YAML.load(ERB.new(IO.read(file)).result) data.each_key do |key| key_id = ActiveRecord::FixtureSet.identify(key) @@ -246,17 +272,21 @@ db_namespace = namespace :db do namespace :schema do desc "Creates a db/schema.rb file that is portable against any DB supported by Active Record" - task dump: [:environment, :load_config] do + task dump: :load_config do require "active_record/schema_dumper" - filename = ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema.rb") - File.open(filename, "w:utf-8") do |file| - ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) + ActiveRecord::Base.configurations.configs_for(env_name: Rails.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) + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) + end end + db_namespace["schema:dump"].reenable end desc "Loads a schema.rb file into the database" - task load: [:environment, :load_config, :check_protected_environments] do + task load: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV["SCHEMA"]) end @@ -266,39 +296,40 @@ db_namespace = namespace :db do namespace :cache do desc "Creates a db/schema_cache.yml file." - task dump: [:environment, :load_config] do + task dump: :load_config do conn = ActiveRecord::Base.connection filename = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema_cache.yml") ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(conn, filename) end desc "Clears a db/schema_cache.yml file." - task clear: [:environment, :load_config] do + task clear: :load_config do filename = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema_cache.yml") rm_f filename, verbose: false end end - end namespace :structure do desc "Dumps the database structure to db/structure.sql. Specify another file with SCHEMA=db/my_structure.sql" - task dump: [:environment, :load_config] do - filename = ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "structure.sql") - current_config = ActiveRecord::Tasks::DatabaseTasks.current_config - ActiveRecord::Tasks::DatabaseTasks.structure_dump(current_config, filename) - - if ActiveRecord::SchemaMigration.table_exists? - File.open(filename, "a") do |f| - f.puts ActiveRecord::Base.connection.dump_schema_information - f.print "\n" + task dump: :load_config do + ActiveRecord::Base.configurations.configs_for(env_name: Rails.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) + if ActiveRecord::SchemaMigration.table_exists? + File.open(filename, "a") do |f| + f.puts ActiveRecord::Base.connection.dump_schema_information + f.print "\n" + end end end + db_namespace["structure:dump"].reenable end desc "Recreates the databases from the structure.sql file" - task load: [:environment, :load_config, :check_protected_environments] do + task load: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:sql, ENV["SCHEMA"]) end @@ -323,26 +354,34 @@ db_namespace = namespace :db do begin should_reconnect = ActiveRecord::Base.connection_pool.active_connection? ActiveRecord::Schema.verbose = false - ActiveRecord::Tasks::DatabaseTasks.load_schema ActiveRecord::Base.configurations["test"], :ruby, ENV["SCHEMA"] + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :ruby) + ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config.config, :ruby, filename, "test") + end ensure if should_reconnect - ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations[ActiveRecord::Tasks::DatabaseTasks.env]) + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations.default_hash(ActiveRecord::Tasks::DatabaseTasks.env)) end end end # desc "Recreate the test database from an existent structure.sql file" task load_structure: %w(db:test:purge) do - ActiveRecord::Tasks::DatabaseTasks.load_schema ActiveRecord::Base.configurations["test"], :sql, ENV["SCHEMA"] + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :sql) + ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config.config, :sql, filename, "test") + end end # desc "Empty the test database" - task purge: %w(environment load_config check_protected_environments) do - ActiveRecord::Tasks::DatabaseTasks.purge ActiveRecord::Base.configurations["test"] + task purge: %w(load_config check_protected_environments) do + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + ActiveRecord::Tasks::DatabaseTasks.purge(db_config.config) + end end # desc 'Load the test schema' - task prepare: %w(environment load_config) do + task prepare: :load_config do unless ActiveRecord::Base.configurations.blank? db_namespace["test:load"].invoke end diff --git a/activerecord/lib/active_record/railties/jdbcmysql_error.rb b/activerecord/lib/active_record/railties/jdbcmysql_error.rb deleted file mode 100644 index d7cf4df339..0000000000 --- a/activerecord/lib/active_record/railties/jdbcmysql_error.rb +++ /dev/null @@ -1,16 +0,0 @@ -#FIXME Remove if ArJdbcMysql will give. -module ArJdbcMySQL #:nodoc: - class Error < StandardError #:nodoc: - attr_accessor :error_number, :sql_state - - def initialize(msg) - super - @error_number = nil - @sql_state = nil - end - - # Mysql gem compatibility - alias_method :errno, :error_number - alias_method :error, :message - end -end diff --git a/activerecord/lib/active_record/readonly_attributes.rb b/activerecord/lib/active_record/readonly_attributes.rb index 6274996ab8..7bc26993d5 100644 --- a/activerecord/lib/active_record/readonly_attributes.rb +++ b/activerecord/lib/active_record/readonly_attributes.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true + module ActiveRecord module ReadonlyAttributes extend ActiveSupport::Concern included do - class_attribute :_attr_readonly, instance_accessor: false - self._attr_readonly = [] + class_attribute :_attr_readonly, instance_accessor: false, default: [] end module ClassMethods diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 61a2279292..b2110f727c 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -1,6 +1,7 @@ -require "thread" +# frozen_string_literal: true + require "active_support/core_ext/string/filters" -require "active_support/deprecation" +require "concurrent/map" module ActiveRecord # = Active Record Reflection @@ -8,38 +9,41 @@ module ActiveRecord extend ActiveSupport::Concern included do - class_attribute :_reflections, instance_writer: false - class_attribute :aggregate_reflections, instance_writer: false - self._reflections = {} - self.aggregate_reflections = {} + class_attribute :_reflections, instance_writer: false, default: {} + class_attribute :aggregate_reflections, instance_writer: false, default: {} end - def self.create(macro, name, scope, options, ar) - klass = \ - case macro - when :composed_of - AggregateReflection - when :has_many - HasManyReflection - when :has_one - HasOneReflection - when :belongs_to - BelongsToReflection - else - raise "Unsupported Macro: #{macro}" - end + class << self + def create(macro, name, scope, options, ar) + reflection = reflection_class_for(macro).new(name, scope, options, ar) + options[:through] ? ThroughReflection.new(reflection) : reflection + end - reflection = klass.new(name, scope, options, ar) - options[:through] ? ThroughReflection.new(reflection) : reflection - end + def add_reflection(ar, name, reflection) + ar.clear_reflections_cache + name = name.to_s + ar._reflections = ar._reflections.except(name).merge!(name => reflection) + end - def self.add_reflection(ar, name, reflection) - ar.clear_reflections_cache - ar._reflections = ar._reflections.merge(name.to_s => reflection) - end + def add_aggregate_reflection(ar, name, reflection) + ar.aggregate_reflections = ar.aggregate_reflections.merge(name.to_s => reflection) + end - def self.add_aggregate_reflection(ar, name, reflection) - ar.aggregate_reflections = ar.aggregate_reflections.merge(name.to_s => reflection) + private + def reflection_class_for(macro) + case macro + when :composed_of + AggregateReflection + when :has_many + HasManyReflection + when :has_one + HasOneReflection + when :belongs_to + BelongsToReflection + else + raise "Unsupported Macro: #{macro}" + end + end end # \Reflection enables the ability to examine the associations and aggregations of @@ -138,7 +142,7 @@ module ActiveRecord # HasAndBelongsToManyReflection # ThroughReflection # PolymorphicReflection - # RuntimeReflection + # RuntimeReflection class AbstractReflection # :nodoc: def through_reflection? false @@ -154,14 +158,6 @@ module ActiveRecord klass.new(attributes, &block) end - def quoted_table_name - klass.quoted_table_name - end - - def primary_key_type - klass.type_for_attribute(klass.primary_key) - end - # Returns the class name for the macro. # # <tt>composed_of :balance, class_name: 'Money'</tt> returns <tt>'Money'</tt> @@ -172,8 +168,8 @@ module ActiveRecord JoinKeys = Struct.new(:key, :foreign_key) # :nodoc: - def join_keys(association_klass) - JoinKeys.new(foreign_key, active_record_primary_key) + def join_keys + @join_keys ||= get_join_keys(klass) end # Returns a list of scopes that should be applied for this Reflection @@ -182,13 +178,46 @@ module ActiveRecord scope ? [scope] : [] end - def scope_chain - chain.map(&:scopes) + def build_join_constraint(table, foreign_table) + key = join_keys.key + foreign_key = join_keys.foreign_key + + constraint = table[key].eq(foreign_table[foreign_key]) + + if klass.finder_needs_type_condition? + table.create_and([constraint, klass.send(:type_condition, table)]) + else + constraint + end + end + + def join_scope(table, foreign_klass) + predicate_builder = predicate_builder(table) + scope_chain_items = join_scopes(table, predicate_builder) + klass_scope = klass_join_scope(table, predicate_builder) + + if type + klass_scope.where!(type => foreign_klass.polymorphic_name) + end + + scope_chain_items.inject(klass_scope, &:merge!) + end + + def join_scopes(table, predicate_builder) # :nodoc: + if scope + [scope_for(build_scope(table, predicate_builder))] + else + [] + end + end + + def klass_join_scope(table, predicate_builder) # :nodoc: + relation = build_scope(table, predicate_builder) + klass.scope_for_association(relation) end - deprecate :scope_chain def constraints - chain.map(&:scopes).flatten + chain.flat_map(&:scopes) end def counter_cache_column @@ -260,6 +289,40 @@ module ActiveRecord def chain collect_join_chain end + + def get_join_keys(association_klass) + JoinKeys.new(join_primary_key(association_klass), join_foreign_key) + end + + def build_scope(table, predicate_builder = predicate_builder(table)) + Relation.create( + klass, + table: table, + predicate_builder: predicate_builder + ) + end + + def join_primary_key(*) + foreign_key + end + + def join_foreign_key + active_record_primary_key + end + + protected + def actual_source_reflection # FIXME: this is a horrible name + self + end + + private + def predicate_builder(table) + PredicateBuilder.new(TableMetadata.new(klass, table)) + end + + def primary_key(klass) + klass.primary_key || raise(UnknownPrimaryKey.new(klass)) + end end # Base class for AggregateReflection and AssociationReflection. Objects of @@ -305,6 +368,17 @@ module ActiveRecord # # <tt>composed_of :balance, class_name: 'Money'</tt> returns the Money class # <tt>has_many :clients</tt> returns the Client class + # + # class Company < ActiveRecord::Base + # has_many :clients + # end + # + # Company.reflect_on_association(:clients).klass + # # => Client + # + # <b>Note:</b> Do not call +klass.new+ or +klass.create+ to instantiate + # a new association object. Use +build_association+ or +create_association+ + # instead. This allows plugins to hook into association object creation. def klass @klass ||= compute_class(class_name) end @@ -323,8 +397,8 @@ module ActiveRecord active_record == other_aggregation.active_record end - def scope_for(klass) - scope ? klass.unscoped.instance_exec(nil, &scope) : klass.unscoped + def scope_for(relation, owner = nil) + relation.instance_exec(owner, &scope) || relation end private @@ -345,23 +419,10 @@ module ActiveRecord # Holds all the metadata about an association as it was specified in the # Active Record class. class AssociationReflection < MacroReflection #:nodoc: - # Returns the target association's class. - # - # class Author < ActiveRecord::Base - # has_many :books - # end - # - # Author.reflect_on_association(:books).klass - # # => Book - # - # <b>Note:</b> Do not call +klass.new+ or +klass.create+ to instantiate - # a new association object. Use +build_association+ or +create_association+ - # instead. This allows plugins to hook into association object creation. - def klass - @klass ||= compute_class(class_name) - end - def compute_class(name) + if polymorphic? + raise ArgumentError, "Polymorphic associations do not support computing the class." + end active_record.send(:compute_type, name) end @@ -370,33 +431,22 @@ module ActiveRecord def initialize(name, scope, options, active_record) super - @automatic_inverse_of = nil @type = options[:as] && (options[:foreign_type] || "#{options[:as]}_type") - @foreign_type = options[:foreign_type] || "#{name}_type" + @foreign_type = options[:polymorphic] && (options[:foreign_type] || "#{name}_type") @constructable = calculate_constructable(macro, options) - @association_scope_cache = {} - @scope_lock = Mutex.new + @association_scope_cache = Concurrent::Map.new if options[:class_name] && options[:class_name].class == Class - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Passing a class to the `class_name` is deprecated and will raise - an ArgumentError in Rails 5.2. It eagerloads more classes than - necessary and potentially creates circular dependencies. - - Please pass the class name as a string: - `#{macro} :#{name}, class_name: '#{options[:class_name]}'` - MSG + raise ArgumentError, "A class was passed to `:class_name` but we are expecting a string." end end - def association_scope_cache(conn, owner) + def association_scope_cache(conn, owner, &block) key = conn.prepared_statements if polymorphic? key = [key, owner._read_attribute(@foreign_type)] end - @association_scope_cache[key] ||= @scope_lock.synchronize { - @association_scope_cache[key] ||= yield - } + @association_scope_cache.compute_if_absent(key) { StatementCache.create(conn, &block) } end def constructable? # :nodoc: @@ -420,10 +470,6 @@ module ActiveRecord options[:primary_key] || primary_key(klass || self.klass) end - def association_primary_key_type - klass.type_for_attribute(association_primary_key.to_s) - end - def active_record_primary_key @active_record_primary_key ||= options[:primary_key] || primary_key(active_record) end @@ -446,7 +492,7 @@ module ActiveRecord alias :check_eager_loadable! :check_preloadable! def join_id_for(owner) # :nodoc: - owner[active_record_primary_key] + owner[join_foreign_key] end def through_reflection @@ -529,7 +575,7 @@ module ActiveRecord end VALID_AUTOMATIC_INVERSE_MACROS = [:has_many, :has_one, :belongs_to] - INVALID_AUTOMATIC_INVERSE_OPTIONS = [:conditions, :through, :polymorphic, :foreign_key] + INVALID_AUTOMATIC_INVERSE_OPTIONS = [:through, :foreign_key] def add_as_source(seed) seed @@ -543,11 +589,9 @@ module ActiveRecord seed + [self] end - protected - - def actual_source_reflection # FIXME: this is a horrible name - self - end + def extensions + Array(options[:extend]) + end private @@ -559,16 +603,30 @@ module ActiveRecord # If it cannot find a suitable inverse association name, it returns # +nil+. def inverse_name - options.fetch(:inverse_of) do - @automatic_inverse_of ||= automatic_inverse_of + unless defined?(@inverse_name) + @inverse_name = options.fetch(:inverse_of) { automatic_inverse_of } end + + @inverse_name end - # returns either false or the inverse association name that it finds. + # returns either +nil+ or the inverse association name that it finds. def automatic_inverse_of - if can_find_inverse_of_automatically?(self) - inverse_name = ActiveSupport::Inflector.underscore(options[:as] || active_record.name.demodulize).to_sym + return unless can_find_inverse_of_automatically?(self) + + inverse_name_candidates = + if options[:as] + [options[:as]] + else + active_record_name = active_record.name.demodulize + [active_record_name, ActiveSupport::Inflector.pluralize(active_record_name)] + end + + inverse_name_candidates.map! do |candidate| + ActiveSupport::Inflector.underscore(candidate).to_sym + end + inverse_name_candidates.detect do |inverse_name| begin reflection = klass._reflect_on_association(inverse_name) rescue NameError @@ -577,24 +635,17 @@ module ActiveRecord reflection = false end - if valid_inverse_reflection?(reflection) - return inverse_name - end + valid_inverse_reflection?(reflection) end - - false end # Checks if the inverse reflection that is returned from the # +automatic_inverse_of+ method is a valid reflection. We must # make sure that the reflection's active_record name matches up # with the current reflection's klass name. - # - # Note: klass will always be valid because when there's a NameError - # from calling +klass+, +reflection+ will already be set to false. def valid_inverse_reflection?(reflection) reflection && - klass.name == reflection.active_record.name && + klass <= reflection.active_record && can_find_inverse_of_automatically?(reflection) end @@ -602,9 +653,8 @@ module ActiveRecord # us from being able to guess the inverse automatically. First, the # <tt>inverse_of</tt> option cannot be set to false. Second, we must # have <tt>has_many</tt>, <tt>has_one</tt>, <tt>belongs_to</tt> associations. - # Third, we must not have options such as <tt>:polymorphic</tt> or - # <tt>:foreign_key</tt> which prevent us from correctly guessing the - # inverse association. + # Third, we must not have options such as <tt>:foreign_key</tt> + # which prevent us from correctly guessing the inverse association. # # Anything with a scope can additionally ruin our attempt at finding an # inverse, so we exclude reflections with scopes. @@ -634,10 +684,6 @@ module ActiveRecord def derive_join_table ModelSchema.derive_join_table_name active_record.table_name, klass.table_name end - - def primary_key(klass) - klass.primary_key || raise(UnknownPrimaryKey.new(klass)) - end end class HasManyReflection < AssociationReflection # :nodoc: @@ -652,6 +698,10 @@ module ActiveRecord Associations::HasManyAssociation end end + + def association_primary_key(klass = nil) + primary_key(klass || self.klass) + end end class HasOneReflection < AssociationReflection # :nodoc: @@ -687,16 +737,18 @@ module ActiveRecord end end - def join_keys(association_klass) - key = polymorphic? ? association_primary_key(association_klass) : association_primary_key - JoinKeys.new(key, foreign_key) + def join_primary_key(klass = nil) + polymorphic? ? association_primary_key(klass) : association_primary_key end - def join_id_for(owner) # :nodoc: - owner[foreign_key] + def join_foreign_key + foreign_key end private + def can_find_inverse_of_automatically?(_) + !polymorphic? && super + end def calculate_constructable(macro, options) !polymorphic? @@ -704,10 +756,6 @@ module ActiveRecord end class HasAndBelongsToManyReflection < AssociationReflection # :nodoc: - def initialize(name, scope, options, active_record) - super - end - def macro; :has_and_belongs_to_many; end def collection? @@ -718,9 +766,8 @@ module ActiveRecord # Holds all the metadata about a :through association as it was specified # in the Active Record class. class ThroughReflection < AbstractReflection #:nodoc: - attr_reader :delegate_reflection - delegate :foreign_key, :foreign_type, :association_foreign_key, - :active_record_primary_key, :type, to: :source_reflection + delegate :foreign_key, :foreign_type, :association_foreign_key, :join_id_for, + :active_record_primary_key, :type, :get_join_keys, to: :source_reflection def initialize(delegate_reflection) @delegate_reflection = delegate_reflection @@ -806,8 +853,8 @@ module ActiveRecord source_reflection.scopes + super end - def source_type_scope - through_reflection.klass.where(foreign_type => options[:source_type]) + def join_scopes(table, predicate_builder) # :nodoc: + source_reflection.join_scopes(table, predicate_builder) + super end def has_scope? @@ -816,10 +863,6 @@ module ActiveRecord through_reflection.has_scope? end - def join_keys(association_klass) - source_reflection.join_keys(association_klass) - end - # A through association is nested if there would be more than one join table def nested? source_reflection.through_reflection? || through_reflection.through_reflection? @@ -834,10 +877,6 @@ module ActiveRecord actual_source_reflection.options[:primary_key] || primary_key(klass || self.klass) end - def association_primary_key_type - klass.type_for_attribute(association_primary_key.to_s) - end - # Gets an array of possible <tt>:through</tt> source reflection names in both singular and plural form. # # class Post < ActiveRecord::Base @@ -882,10 +921,6 @@ module ActiveRecord through_reflection.options end - def join_id_for(owner) # :nodoc: - source_reflection.join_id_for(owner) - end - def check_validity! if through_reflection.nil? raise HasManyThroughAssociationNotFoundError.new(active_record.name, self) @@ -944,22 +979,21 @@ module ActiveRecord collect_join_reflections(seed + [self]) end - def collect_join_reflections(seed) - a = source_reflection.add_as_source seed - if options[:source_type] - through_reflection.add_as_polymorphic_through self, a - else - through_reflection.add_as_through a + protected + def actual_source_reflection # FIXME: this is a horrible name + source_reflection.actual_source_reflection end - end private - def actual_source_reflection # FIXME: this is a horrible name - source_reflection.send(:actual_source_reflection) - end + attr_reader :delegate_reflection - def primary_key(klass) - klass.primary_key || raise(UnknownPrimaryKey.new(klass)) + def collect_join_reflections(seed) + a = source_reflection.add_as_source seed + if options[:source_type] + through_reflection.add_as_polymorphic_through self, a + else + through_reflection.add_as_through a + end end def inverse_name; delegate_reflection.send(:inverse_name); end @@ -976,57 +1010,32 @@ module ActiveRecord end class PolymorphicReflection < AbstractReflection # :nodoc: + delegate :klass, :scope, :plural_name, :type, :get_join_keys, :scope_for, to: :@reflection + def initialize(reflection, previous_reflection) @reflection = reflection @previous_reflection = previous_reflection end - def scopes - scopes = @previous_reflection.scopes - if @previous_reflection.options[:source_type] - scopes + [@previous_reflection.source_type_scope] - else - scopes - end - end - - def klass - @reflection.klass - end - - def scope - @reflection.scope - end - - def table_name - @reflection.table_name - end - - def plural_name - @reflection.plural_name - end - - def join_keys(association_klass) - @reflection.join_keys(association_klass) - end - - def type - @reflection.type + def join_scopes(table, predicate_builder) # :nodoc: + scopes = @previous_reflection.join_scopes(table, predicate_builder) + super + scopes << build_scope(table, predicate_builder).instance_exec(nil, &source_type_scope) end def constraints - @reflection.constraints + [source_type_info] + @reflection.constraints + [source_type_scope] end - def source_type_info - type = @previous_reflection.foreign_type - source_type = @previous_reflection.options[:source_type] - lambda { |object| where(type => source_type) } - end + private + def source_type_scope + type = @previous_reflection.foreign_type + source_type = @previous_reflection.options[:source_type] + lambda { |object| where(type => source_type) } + end end - class RuntimeReflection < PolymorphicReflection # :nodoc: - attr_accessor :next + class RuntimeReflection < AbstractReflection # :nodoc: + delegate :scope, :type, :constraints, :get_join_keys, to: :@reflection def initialize(reflection, association) @reflection = reflection @@ -1037,24 +1046,8 @@ module ActiveRecord @association.klass end - def table_name - klass.table_name - end - - def constraints - @reflection.constraints - end - - def source_type_info - @reflection.source_type_info - end - - def alias_candidate(name) - "#{plural_name}_#{name}_join" - end - - def alias_name - Arel::Table.new(table_name) + def aliased_table + @aliased_table ||= Arel::Table.new(table_name, type_caster: klass.type_caster) end def all_includes; yield; end diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 61ee09bcc8..86e5d14c81 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -1,14 +1,17 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record \Relation class Relation MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group, - :order, :joins, :left_joins, :left_outer_joins, :references, + :order, :joins, :left_outer_joins, :references, :extending, :unscope] SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, - :reverse_order, :distinct, :create_with] + :reverse_order, :distinct, :create_with, :skip_query_cache] + CLAUSE_METHODS = [:where, :having, :from] - INVALID_METHODS_FOR_DELETE_ALL = [:limit, :distinct, :offset, :group, :having] + INVALID_METHODS_FOR_DELETE_ALL = [:distinct, :group, :having] VALUE_METHODS = MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS + CLAUSE_METHODS @@ -16,16 +19,19 @@ module ActiveRecord include FinderMethods, Calculations, SpawnMethods, QueryMethods, Batches, Explain, Delegation attr_reader :table, :klass, :loaded, :predicate_builder + attr_accessor :skip_preloading_value alias :model :klass alias :loaded? :loaded + alias :locked? :lock_value - def initialize(klass, table, predicate_builder, values = {}) + def initialize(klass, table: klass.arel_table, predicate_builder: klass.predicate_builder, values: {}) @klass = klass @table = table @values = values @offsets = {} @loaded = false @predicate_builder = predicate_builder + @delegate_to_klass = false end def initialize_copy(other) @@ -33,78 +39,16 @@ module ActiveRecord reset end - def insert(values) # :nodoc: - primary_key_value = nil - - if primary_key && Hash === values - primary_key_value = values[values.keys.find { |k| - k.name == primary_key - }] - - if !primary_key_value && klass.prefetch_primary_key? - primary_key_value = klass.next_sequence_value - values[arel_attribute(klass.primary_key)] = primary_key_value - end - end - - im = arel.create_insert - im.into @table - - substitutes, binds = substitute_values values - - if values.empty? # empty insert - im.values = Arel.sql(connection.empty_insert_statement_value) - else - im.insert substitutes - end - - @klass.connection.insert( - im, - "SQL", - primary_key || false, - primary_key_value, - nil, - binds) - end - - def _update_record(values, id, id_was) # :nodoc: - substitutes, binds = substitute_values values - - scope = @klass.unscoped - - if @klass.finder_needs_type_condition? - scope.unscope!(where: @klass.inheritance_column) - end - - relation = scope.where(@klass.primary_key => (id_was || id)) - bvs = binds + relation.bound_attributes - um = relation - .arel - .compile_update(substitutes, @klass.primary_key) - - @klass.connection.update( - um, - "SQL", - bvs, - ) - end - - def substitute_values(values) # :nodoc: - binds = [] - substitutes = [] - - values.each do |arel_attr, value| - binds.push QueryAttribute.new(arel_attr.name, value, klass.type_for_attribute(arel_attr.name)) - substitutes.push [arel_attr, Arel::Nodes::BindParam.new] - end - - [substitutes, binds] - end - def arel_attribute(name) # :nodoc: klass.arel_attribute(name, table) end + def bind_attribute(name, value) # :nodoc: + attr = arel_attribute(name) + bind = predicate_builder.build_bind_attribute(attr.name, value) + yield attr, bind + end + # Initializes new record from relation while maintaining the current # scope. # @@ -117,8 +61,8 @@ module ActiveRecord # # user = users.new { |user| user.name = 'Oscar' } # user.name # => Oscar - def new(*args, &block) - scoping { @klass.new(*args, &block) } + def new(attributes = nil, &block) + scoping { klass.new(attributes, &block) } end alias build new @@ -142,8 +86,8 @@ module ActiveRecord # # users.create(name: nil) # validation on name # # => #<User id: nil, name: nil, ...> - def create(*args, &block) - scoping { @klass.create(*args, &block) } + def create(attributes = nil, &block) + scoping { klass.create(attributes, &block) } end # Similar to #create, but calls @@ -152,8 +96,8 @@ module ActiveRecord # # Expects arguments in the same format as # {ActiveRecord::Base.create!}[rdoc-ref:Persistence::ClassMethods#create!]. - def create!(*args, &block) - scoping { @klass.create!(*args, &block) } + def create!(attributes = nil, &block) + scoping { klass.create!(attributes, &block) } end def first_or_create(attributes = nil, &block) # :nodoc: @@ -199,23 +143,12 @@ module ActiveRecord # failed due to validation errors it won't be persisted, you get what # #create returns in such situation. # - # Please note *this method is not atomic*, it runs first a SELECT, and if + # Please note <b>this method is not atomic</b>, it runs first a SELECT, and if # there are no results an INSERT is attempted. If there are other threads # or processes there is a race condition between both calls and it could # be the case that you end up with two similar records. # - # Whether that is a problem or not depends on the logic of the - # application, but in the particular case in which rows have a UNIQUE - # constraint an exception may be raised, just retry: - # - # begin - # CreditAccount.transaction(requires_new: true) do - # CreditAccount.find_or_create_by(user_id: user.id) - # end - # rescue ActiveRecord::RecordNotUnique - # retry - # end - # + # If this might be a problem for your application, please see #create_or_find_by. def find_or_create_by(attributes, &block) find_by(attributes) || create(attributes, &block) end @@ -227,6 +160,47 @@ module ActiveRecord find_by(attributes) || create!(attributes, &block) end + # Attempts to create a record with the given attributes in a table that has a unique constraint + # on one or several of its columns. If a row already exists with one or several of these + # unique constraints, the exception such an insertion would normally raise is caught, + # and the existing record with those attributes is found using #find_by. + # + # This is similar to #find_or_create_by, but avoids the problem of stale reads between the SELECT + # and the INSERT, as that method needs to first query the table, then attempt to insert a row + # if none is found. + # + # There are several drawbacks to #create_or_find_by, though: + # + # * The underlying table must have the relevant columns defined with unique constraints. + # * A unique constraint violation may be triggered by only one, or at least less than all, + # of the given attributes. This means that the subsequent #find_by may fail to find a + # matching record, which will then raise an <tt>ActiveRecord::RecordNotFound</tt> exception, + # rather than a record with the given attributes. + # * While we avoid the race condition between SELECT -> INSERT from #find_or_create_by, + # we actually have another race condition between INSERT -> SELECT, which can be triggered + # if a DELETE between those two statements is run by another client. But for most applications, + # that's a significantly less likely condition to hit. + # * It relies on exception handling to handle control flow, which may be marginally slower. + # + # This method will return a record if all given attributes are covered by unique constraints + # (unless the INSERT -> DELETE -> SELECT race condition is triggered), but if creation was attempted + # and failed due to validation errors it won't be persisted, you get what #create returns in + # such situation. + def create_or_find_by(attributes, &block) + transaction(requires_new: true) { create(attributes, &block) } + rescue ActiveRecord::RecordNotUnique + find_by!(attributes) + end + + # Like #create_or_find_by, but calls + # {create!}[rdoc-ref:Persistence::ClassMethods#create!] so an exception + # is raised if the created record is invalid. + def create_or_find_by!(attributes, &block) + transaction(requires_new: true) { create!(attributes, &block) } + rescue ActiveRecord::RecordNotUnique + find_by!(attributes) + end + # Like #find_or_create_by, but calls {new}[rdoc-ref:Core#new] # instead of {create}[rdoc-ref:Persistence::ClassMethods#create]. def find_or_initialize_by(attributes, &block) @@ -241,15 +215,16 @@ module ActiveRecord # are needed by the next ones when eager loading is going on. # # Please see further details in the - # {Active Record Query Interface guide}[http://guides.rubyonrails.org/active_record_querying.html#running-explain]. + # {Active Record Query Interface guide}[https://guides.rubyonrails.org/active_record_querying.html#running-explain]. def explain exec_explain(collecting_queries_for_explain { exec_queries }) end # Converts relation objects to Array. - def to_a + def to_ary records.dup end + alias to_a to_ary def records # :nodoc: load @@ -261,10 +236,6 @@ module ActiveRecord coder.represent_seq(nil, records) end - def as_json(options = nil) #:nodoc: - records.as_json(options) - end - # Returns size of the records. def size loaded? ? @records.length : count(:all) @@ -273,8 +244,7 @@ module ActiveRecord # Returns true if there are no records. def empty? return @records.empty? if loaded? - - limit_value == 0 || !exists? + !exists? end # Returns true if there are no records. @@ -337,10 +307,14 @@ module ActiveRecord # Please check unscoped if you want to remove all previous scopes (including # the default_scope) during the execution of a block. def scoping - previous, klass.current_scope = klass.current_scope, self - yield + @delegate_to_klass ? yield : klass._scoping(self) { yield } + end + + def _exec_scope(*args, &block) # :nodoc: + @delegate_to_klass = true + instance_exec(*args, &block) || self ensure - klass.current_scope = previous + @delegate_to_klass = false end # Updates all records in the current relation with details given. This method constructs a single SQL UPDATE @@ -368,12 +342,17 @@ module ActiveRecord def update_all(updates) raise ArgumentError, "Empty list of attributes to change" if updates.blank? + if eager_loading? + relation = apply_join_dependency + return relation.update_all(updates) + end + stmt = Arel::UpdateManager.new - stmt.set Arel.sql(@klass.send(:sanitize_sql_for_assignment, updates)) + stmt.set Arel.sql(@klass.sanitize_sql_for_assignment(updates)) stmt.table(table) - if has_join_values? + if has_join_values? || offset_value @klass.connection.join_to_update(stmt, arel, arel_attribute(primary_key)) else stmt.key = arel_attribute(primary_key) @@ -382,51 +361,67 @@ module ActiveRecord stmt.wheres = arel.constraints end - @klass.connection.update stmt, "SQL", bound_attributes + @klass.connection.update stmt, "#{@klass} Update All" end - # Updates an object (or multiple objects) and saves it to the database, if validations pass. - # The resulting object is returned whether the object was saved successfully to the database or not. + def update(id = :all, attributes) # :nodoc: + if id == :all + each { |record| record.update(attributes) } + else + klass.update(id, attributes) + end + end + + def update_counters(counters) # :nodoc: + touch = counters.delete(:touch) + + updates = counters.map do |counter_name, value| + operator = value < 0 ? "-" : "+" + quoted_column = connection.quote_column_name(counter_name) + "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{value.abs}" + end + + if touch + names = touch if touch != true + touch_updates = klass.touch_attributes_with_time(*names) + updates << klass.sanitize_sql_for_assignment(touch_updates) unless touch_updates.empty? + end + + update_all updates.join(", ") + end + + # Touches all records in the current relation without instantiating records first with the updated_at/on attributes + # set to the current time or the time specified. + # This method can be passed attribute names and an optional time argument. + # If attribute names are passed, they are updated along with updated_at/on attributes. + # If no time argument is passed, the current time is used as default. # - # ==== Parameters + # === Examples # - # * +id+ - This should be the id or an array of ids to be updated. - # * +attributes+ - This should be a hash of attributes or an array of hashes. + # # Touch all records + # Person.all.touch_all + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670'" # - # ==== Examples + # # Touch multiple records with a custom attribute + # Person.all.touch_all(:created_at) + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670', \"created_at\" = '2018-01-04 22:55:23.132670'" # - # # Updates one record - # Person.update(15, user_name: 'Samuel', group: 'expert') - # - # # Updates multiple records - # people = { 1 => { "first_name" => "David" }, 2 => { "first_name" => "Jeremy" } } - # Person.update(people.keys, people.values) - # - # # Updates multiple records from the result of a relation - # people = Person.where(group: 'expert') - # people.update(group: 'masters') - # - # Note: Updating a large number of records will run an - # UPDATE query for each record, which may cause a performance - # issue. So if it is not needed to run callbacks for each update, it is - # preferred to use #update_all for updating all records using - # a single query. - def update(id = :all, attributes) - if id.is_a?(Array) - id.map.with_index { |one_id, idx| update(one_id, attributes[idx]) } - elsif id == :all - records.each { |record| record.update(attributes) } - else - if ActiveRecord::Base === id - raise ArgumentError, <<-MSG.squish - You are passing an instance of ActiveRecord::Base to `update`. - Please pass the id of the object by calling `.id`. - MSG - end - object = find(id) - object.update(attributes) - object + # # Touch multiple records with a specified time + # Person.all.touch_all(time: Time.new(2020, 5, 16, 0, 0, 0)) + # # => "UPDATE \"people\" SET \"updated_at\" = '2020-05-16 00:00:00'" + # + # # Touch records with scope + # Person.where(name: 'David').touch_all + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670' WHERE \"people\".\"name\" = 'David'" + def touch_all(*names, time: nil) + updates = touch_attributes_with_time(*names, time: time) + + if klass.locking_enabled? + quoted_locking_column = connection.quote_column_name(klass.locking_column) + updates = sanitize_sql_for_assignment(updates) + ", #{quoted_locking_column} = COALESCE(#{quoted_locking_column}, 0) + 1" end + + update_all(updates) end # Destroys the records by instantiating each @@ -449,33 +444,6 @@ module ActiveRecord records.each(&:destroy).tap { reset } end - # Destroy an object (or multiple objects) that has the given id. The object is instantiated first, - # therefore all callbacks and filters are fired off before the object is deleted. This method is - # less efficient than #delete but allows cleanup methods and other actions to be run. - # - # This essentially finds the object (or multiple objects) with the given id, creates a new object - # from the attributes, and then calls destroy on it. - # - # ==== Parameters - # - # * +id+ - Can be either an Integer or an Array of Integers. - # - # ==== Examples - # - # # Destroy a single object - # Todo.destroy(1) - # - # # Destroy multiple objects - # todos = [1,2,3] - # Todo.destroy(todos) - def destroy(id) - if id.is_a?(Array) - id.map { |one_id| destroy(one_id) } - else - find(id).destroy - end - end - # Deletes the records without instantiating the records # first, and hence not calling the {#destroy}[rdoc-ref:Persistence#destroy] # method nor invoking callbacks. @@ -492,8 +460,8 @@ module ActiveRecord # # If an invalid method is supplied, #delete_all raises an ActiveRecordError: # - # Post.limit(100).delete_all - # # => ActiveRecord::ActiveRecordError: delete_all doesn't support limit + # Post.distinct.delete_all + # # => 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) @@ -503,44 +471,26 @@ module ActiveRecord raise ActiveRecordError.new("delete_all doesn't support #{invalid_methods.join(', ')}") end + if eager_loading? + relation = apply_join_dependency + return relation.delete_all + end + stmt = Arel::DeleteManager.new stmt.from(table) - if has_join_values? + if has_join_values? || has_limit_or_offset? @klass.connection.join_to_delete(stmt, arel, arel_attribute(primary_key)) else stmt.wheres = arel.constraints end - affected = @klass.connection.delete(stmt, "SQL", bound_attributes) + affected = @klass.connection.delete(stmt, "#{@klass} Destroy") reset affected end - # Deletes the row with a primary key matching the +id+ argument, using a - # SQL +DELETE+ statement, and returns the number of rows deleted. Active - # Record objects are not instantiated, so the object's callbacks are not - # executed, including any <tt>:dependent</tt> association options. - # - # You can delete multiple rows at once by passing an Array of <tt>id</tt>s. - # - # Note: Although it is often much faster than the alternative, - # #destroy, skipping callbacks might bypass business logic in - # your application that ensures referential integrity or performs other - # essential jobs. - # - # ==== Examples - # - # # Delete a single row - # Todo.delete(1) - # - # # Delete multiple rows - # Todo.delete([2,3,4]) - def delete(id_or_array) - where(primary_key => id_or_array).delete_all - end - # Causes the records to be loaded from the database if they have not # been loaded already. You can use this if for some reason you need # to explicitly load some records before actually using them. The @@ -560,8 +510,8 @@ module ActiveRecord end def reset - @last = @to_sql = @order_clause = @scope_for_create = @arel = @loaded = nil - @should_eager_load = @join_dependency = nil + @delegate_to_klass = false + @to_sql = @arel = @loaded = @should_eager_load = nil @records = [].freeze @offsets = {} self @@ -573,29 +523,28 @@ module ActiveRecord # # => SELECT "users".* FROM "users" WHERE "users"."name" = 'Oscar' def to_sql @to_sql ||= begin - relation = self - - if eager_loading? - find_with_associations { |rel| relation = rel } - end - - conn = klass.connection - conn.unprepared_statement { - conn.to_sql(relation.arel, relation.bound_attributes) - } - end + if eager_loading? + apply_join_dependency do |relation, join_dependency| + relation = join_dependency.apply_column_aliases(relation) + relation.to_sql + end + else + conn = klass.connection + conn.unprepared_statement { conn.to_sql(arel) } + end + end end # Returns a hash of where conditions. # # User.where(name: 'Oscar').where_values_hash # # => {name: "Oscar"} - def where_values_hash(relation_table_name = table_name) + def where_values_hash(relation_table_name = klass.table_name) where_clause.to_h(relation_table_name) end def scope_for_create - @scope_for_create ||= where_values_hash.merge(create_with_value) + where_values_hash.merge!(create_with_value.stringify_keys) end # Returns true if relation needs eager loading. @@ -639,12 +588,37 @@ module ActiveRecord end def inspect - entries = records.take([limit_value, 11].compact.min).map!(&:inspect) + subject = loaded? ? records : self + entries = subject.take([limit_value, 11].compact.min).map!(&:inspect) + entries[10] = "..." if entries.size == 11 "#<#{self.class.name} [#{entries.join(', ')}]>" end + def empty_scope? # :nodoc: + @values == klass.unscoped.values + end + + def has_limit_or_offset? # :nodoc: + limit_value || offset_value + end + + def alias_tracker(joins = [], aliases = nil) # :nodoc: + joins += [aliases] if aliases + ActiveRecord::Associations::AliasTracker.create(connection, table.name, joins) + end + + def preload_associations(records) # :nodoc: + preload = preload_values + preload += includes_values unless eager_loading? + preloader = nil + preload.each do |associations| + preloader ||= build_preloader + preloader.preload records, associations + end + end + protected def load_records(records) @@ -659,20 +633,39 @@ module ActiveRecord end def exec_queries(&block) - @records = eager_loading? ? find_with_associations.freeze : @klass.find_by_sql(arel, bound_attributes, &block).freeze - - preload = preload_values - preload += includes_values unless eager_loading? - preloader = nil - preload.each do |associations| - preloader ||= build_preloader - preloader.preload @records, associations + skip_query_cache_if_necessary do + @records = + if eager_loading? + apply_join_dependency do |relation, join_dependency| + if ActiveRecord::NullRelation === relation + [] + else + relation = join_dependency.apply_column_aliases(relation) + rows = connection.select_all(relation.arel, "SQL") + join_dependency.instantiate(rows, &block) + end.freeze + end + else + klass.find_by_sql(arel, &block).freeze + end + + preload_associations(@records) unless skip_preloading_value + + @records.each(&:readonly!) if readonly_value + + @loaded = true + @records end + end - @records.each(&:readonly!) if readonly_value - - @loaded = true - @records + def skip_query_cache_if_necessary + if skip_query_cache_value + uncached do + yield + end + else + yield + end end def build_preloader diff --git a/activerecord/lib/active_record/relation/batches.rb b/activerecord/lib/active_record/relation/batches.rb index 76031515fd..9c579843b1 100644 --- a/activerecord/lib/active_record/relation/batches.rb +++ b/activerecord/lib/active_record/relation/batches.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/relation/batches/batch_enumerator" module ActiveRecord @@ -30,14 +32,14 @@ module ActiveRecord # end # # ==== Options - # * <tt>:batch_size</tt> - Specifies the size of the batch. Default to 1000. + # * <tt>:batch_size</tt> - Specifies the size of the batch. Defaults to 1000. # * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value. # * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value. # * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when - # an order is present in the relation. + # an order is present in the relation. # # Limits are honored, and if present there is no requirement for the batch - # size, it can be less than, equal, or greater than the limit. + # size: it can be less than, equal to, or greater than the limit. # # The options +start+ and +finish+ are especially useful if you want # multiple workers dealing with the same processing queue. You can make @@ -45,7 +47,12 @@ module ActiveRecord # handle from 10000 and beyond by setting the +:start+ and +:finish+ # option on each worker. # - # # Let's process from record 10_000 on. + # # In worker 1, let's process until 9999 records. + # Person.find_each(finish: 9_999) do |person| + # person.party_all_night! + # end + # + # # In worker 2, let's process from record 10_000 and onwards. # Person.find_each(start: 10_000) do |person| # person.party_all_night! # end @@ -89,14 +96,14 @@ module ActiveRecord # To be yielded each record one by one, use #find_each instead. # # ==== Options - # * <tt>:batch_size</tt> - Specifies the size of the batch. Default to 1000. + # * <tt>:batch_size</tt> - Specifies the size of the batch. Defaults to 1000. # * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value. # * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value. # * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when - # an order is present in the relation. + # an order is present in the relation. # # Limits are honored, and if present there is no requirement for the batch - # size, it can be less than, equal, or greater than the limit. + # size: it can be less than, equal to, or greater than the limit. # # The options +start+ and +finish+ are especially useful if you want # multiple workers dealing with the same processing queue. You can make @@ -140,9 +147,9 @@ module ActiveRecord # If you do not provide a block to #in_batches, it will return a # BatchEnumerator which is enumerable. # - # Person.in_batches.with_index do |relation, batch_index| + # Person.in_batches.each_with_index do |relation, batch_index| # puts "Processing relation ##{batch_index}" - # relation.each { |relation| relation.delete_all } + # relation.delete_all # end # # Examples of calling methods on the returned BatchEnumerator object: @@ -152,12 +159,12 @@ module ActiveRecord # Person.in_batches.each_record(&:party_all_night!) # # ==== Options - # * <tt>:of</tt> - Specifies the size of the batch. Default to 1000. - # * <tt>:load</tt> - Specifies if the relation should be loaded. Default to false. + # * <tt>:of</tt> - Specifies the size of the batch. Defaults to 1000. + # * <tt>:load</tt> - Specifies if the relation should be loaded. Defaults to false. # * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value. # * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value. # * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when - # an order is present in the relation. + # an order is present in the relation. # # Limits are honored, and if present there is no requirement for the batch # size, it can be less than, equal, or greater than the limit. @@ -186,7 +193,7 @@ module ActiveRecord # # NOTE: It's not possible to set the order. That is automatically set to # ascending on the primary key ("id ASC") to make the batch ordering - # consistent. Therefore the primary key must be orderable, e.g an integer + # consistent. Therefore the primary key must be orderable, e.g. an integer # or a string. # # NOTE: By its nature, batch processing is subject to race conditions if @@ -209,6 +216,7 @@ module ActiveRecord relation = relation.reorder(batch_order).limit(batch_limit) relation = apply_limits(relation, start, finish) + relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching batch_relation = relation loop do @@ -243,20 +251,30 @@ module ActiveRecord end end - batch_relation = relation.where(arel_attribute(primary_key).gt(primary_key_offset)) + batch_relation = relation.where( + bind_attribute(primary_key, primary_key_offset) { |attr, bind| attr.gt(bind) } + ) end end private def apply_limits(relation, start, finish) - relation = relation.where(arel_attribute(primary_key).gteq(start)) if start - relation = relation.where(arel_attribute(primary_key).lteq(finish)) if finish + relation = apply_start_limit(relation, start) if start + relation = apply_finish_limit(relation, finish) if finish relation end + def apply_start_limit(relation, start) + relation.where(bind_attribute(primary_key, start) { |attr, bind| attr.gteq(bind) }) + end + + def apply_finish_limit(relation, finish) + relation.where(bind_attribute(primary_key, finish) { |attr, bind| attr.lteq(bind) }) + end + def batch_order - "#{quoted_table_name}.#{quoted_primary_key} ASC" + arel_attribute(primary_key).asc end def act_on_ignored_order(error_on_ignore) diff --git a/activerecord/lib/active_record/relation/batches/batch_enumerator.rb b/activerecord/lib/active_record/relation/batches/batch_enumerator.rb index 3555779ec2..49697da3bf 100644 --- a/activerecord/lib/active_record/relation/batches/batch_enumerator.rb +++ b/activerecord/lib/active_record/relation/batches/batch_enumerator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Batches class BatchEnumerator diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index f4cdaf3948..0fa5ba2e50 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Calculations # Count the records. @@ -38,10 +40,16 @@ module ActiveRecord # between databases. In invalid cases, an error from the database is thrown. def count(column_name = nil) if block_given? - to_a.count { |*block_args| yield(*block_args) } - else - calculate(:count, column_name) + unless column_name.nil? + ActiveSupport::Deprecation.warn \ + "When `count' is called with a block, it ignores other arguments. " \ + "This behavior is now deprecated and will result in an ArgumentError in Rails 6.0." + end + + return super() end + + calculate(:count, column_name) end # Calculates the average value on a given column. Returns +nil+ if there's @@ -75,8 +83,17 @@ module ActiveRecord # #calculate for examples with options. # # Person.sum(:age) # => 4562 - def sum(column_name = nil, &block) - return super(&block) if block_given? + def sum(column_name = nil) + if block_given? + unless column_name.nil? + ActiveSupport::Deprecation.warn \ + "When `sum' is called with a block, it ignores other arguments. " \ + "This behavior is now deprecated and will result in an ArgumentError in Rails 6.0." + end + + return super() + end + calculate(:sum, column_name) end @@ -113,8 +130,15 @@ module ActiveRecord # end def calculate(operation, column_name) if has_include?(column_name) - relation = construct_relation_for_association_calculations - relation = relation.distinct if operation.to_s.downcase == "count" + 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 = [] + end + end relation.calculate(operation, column_name) else @@ -163,17 +187,37 @@ module ActiveRecord end if has_include?(column_names.first) - construct_relation_for_association_calculations.pluck(*column_names) + relation = apply_join_dependency + relation.pluck(*column_names) else + disallow_raw_sql!(column_names) relation = spawn relation.select_values = column_names.map { |cn| @klass.has_attribute?(cn) || @klass.attribute_alias?(cn) ? arel_attribute(cn) : cn } - result = klass.connection.select_all(relation.arel, nil, bound_attributes) + result = skip_query_cache_if_necessary { klass.connection.select_all(relation.arel, nil) } result.cast_values(klass.attribute_types) end end + # Pick the value(s) from the named column(s) in the current relation. + # This is short-hand for <tt>relation.limit(1).pluck(*column_names).first</tt>, and is primarily useful + # when you have a relation that's already narrowed down to a single row. + # + # Just like #pluck, #pick will only load the actual value, not the entire record object, so it's also + # more efficient. The value is, again like with pluck, typecast by the column type. + # + # Person.where(id: 1).pick(:name) + # # SELECT people.name FROM people WHERE id = 1 LIMIT 1 + # # => 'David' + # + # Person.where(id: 1).pick(:name, :email_address) + # # SELECT people.name, people.email_address FROM people WHERE id = 1 LIMIT 1 + # # => [ 'David', 'david@loudthinking.com' ] + def pick(*column_names) + limit(1).pluck(*column_names).first + end + # Pluck all the ID's for the relation using the table's primary key # # Person.ids # SELECT people.id FROM people @@ -197,8 +241,13 @@ module ActiveRecord if operation == "count" column_name ||= select_for_count - column_name = primary_key if column_name == :all && distinct - distinct = nil if column_name =~ /\s*DISTINCT[\s(]+/i + if column_name == :all + if distinct && (group_values.any? || select_values.empty? && order_values.empty?) + column_name = primary_key + end + elsif /\s*DISTINCT[\s(]+/i.match?(column_name.to_s) + distinct = nil + end end if group_values.any? @@ -211,7 +260,7 @@ module ActiveRecord def aggregate_column(column_name) return column_name if Arel::Expressions === column_name - if @klass.has_attribute?(column_name.to_s) || @klass.attribute_alias?(column_name.to_s) + 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) @@ -225,7 +274,7 @@ module ActiveRecord def execute_simple_calculation(operation, column_name, distinct) #:nodoc: column_alias = column_name - if operation == "count" && (limit_value || offset_value) + if operation == "count" && (column_name == :all && distinct || has_limit_or_offset?) # Shortcut when limit is zero. return 0 if limit_value == 0 @@ -237,6 +286,9 @@ module ActiveRecord column = aggregate_column(column_name) select_value = operation_over_aggregate_column(column, operation, distinct) + if operation == "sum" && distinct + select_value.distinct = true + end column_alias = select_value.alias column_alias ||= @klass.connection.column_name_for_operation(operation, select_value) @@ -245,7 +297,7 @@ module ActiveRecord query_builder = relation.arel end - result = @klass.connection.select_all(query_builder, nil, bound_attributes) + result = skip_query_cache_if_necessary { @klass.connection.select_all(query_builder, nil) } row = result.first value = row && row.values.first type = result.column_types.fetch(column_alias) do @@ -296,7 +348,7 @@ module ActiveRecord relation.group_values = group_fields relation.select_values = select_values - calculated_data = @klass.connection.select_all(relation, nil, relation.bound_attributes) + calculated_data = skip_query_cache_if_necessary { @klass.connection.select_all(relation.arel, nil) } if association key_ids = calculated_data.collect { |row| row[group_aliases.first] } @@ -364,16 +416,17 @@ module ActiveRecord end def build_count_subquery(relation, column_name, distinct) - column_alias = Arel.sql("count_column") - subquery_alias = Arel.sql("subquery_for_count") + if column_name == :all + 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 - aliased_column = aggregate_column(column_name == :all ? 1 : column_name).as(column_alias) - relation.select_values = [aliased_column] - subquery = relation.arel.as(subquery_alias) + subquery = relation.arel.as(Arel.sql("subquery_for_count")) + select_value = operation_over_aggregate_column(column_alias || Arel.star, "count", false) - sm = Arel::SelectManager.new relation.engine - select_value = operation_over_aggregate_column(column_alias, "count", distinct) - sm.project(select_value).from(subquery) + Arel::SelectManager.new(subquery).project(select_value) end end end diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index d3ba724507..488f71cdde 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Delegation # :nodoc: module DelegateCache # :nodoc: @@ -36,13 +38,12 @@ module ActiveRecord # may vary depending on the klass of a relation, so we create a subclass of Relation # for each different klass, and the delegations are compiled into that subclass only. - delegate :to_xml, :encode_with, :length, :collect, :map, :each, :all?, :include?, :to_ary, :join, - :[], :&, :|, :+, :-, :sample, :reverse, :compact, :in_groups, :in_groups_of, - :to_sentence, :to_formatted_s, - :shuffle, :split, :index, to: :records + delegate :to_xml, :encode_with, :length, :each, :join, + :[], :&, :|, :+, :-, :sample, :reverse, :rotate, :compact, :in_groups, :in_groups_of, + :to_sentence, :to_formatted_s, :as_json, + :shuffle, :split, :slice, :index, :rindex, to: :records - delegate :table_name, :quoted_table_name, :primary_key, :quoted_primary_key, - :connection, :columns_hash, to: :klass + delegate :primary_key, :connection, to: :klass module ClassSpecificRelation # :nodoc: extend ActiveSupport::Concern @@ -73,13 +74,6 @@ module ActiveRecord end end end - - def delegate(method, opts = {}) - @delegation_mutex.synchronize do - return if method_defined?(method) - super - end - end end private @@ -88,8 +82,14 @@ module ActiveRecord if @klass.respond_to?(method) self.class.delegate_to_scoped_klass(method) scoping { @klass.public_send(method, *args, &block) } + elsif @delegate_to_klass && @klass.respond_to?(method, true) + ActiveSupport::Deprecation.warn \ + "Delegating missing #{method} method to #{@klass}. " \ + "Accessibility of private/protected class methods in :scope is deprecated and will be removed in Rails 6.0." + @klass.send(method, *args, &block) elsif arel.respond_to?(method) - self.class.delegate method, to: :arel + ActiveSupport::Deprecation.warn \ + "Delegating #{method} to arel is deprecated and will be removed in Rails 6.0." arel.public_send(method, *args, &block) else super @@ -109,21 +109,9 @@ module ActiveRecord end end - def respond_to_missing?(method, include_private = false) - super || @klass.respond_to?(method, include_private) || - arel.respond_to?(method, include_private) - end - private - - def method_missing(method, *args, &block) - if @klass.respond_to?(method) - scoping { @klass.public_send(method, *args, &block) } - elsif arel.respond_to?(method) - arel.public_send(method, *args, &block) - else - super - end + def respond_to_missing?(method, _) + super || @klass.respond_to?(method) || arel.respond_to?(method) end end end diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 5d24f5f5ca..0fabfe5518 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/string/filters" module ActiveRecord @@ -16,9 +18,10 @@ module ActiveRecord # Person.find([1]) # returns an array for the object with ID = 1 # Person.where("administrator = 1").order("created_on DESC").find(1) # - # NOTE: The returned records may not be in the same order as the ids you - # provide since database rows are unordered. You will need to provide an explicit QueryMethods#order - # option if you want the results to be sorted. + # NOTE: The returned records are in the same order as the ids you provide. + # If you want the results to be sorted by database, you can use ActiveRecord::QueryMethods#where + # method and provide an explicit ActiveRecord::QueryMethods#order option. + # But ActiveRecord::QueryMethods#where method doesn't raise ActiveRecord::RecordNotFound. # # ==== Find with lock # @@ -86,7 +89,7 @@ module ActiveRecord where(arg, *args).take! rescue ::RangeError raise RecordNotFound.new("Couldn't find #{@klass.name} with an out of range value", - @klass.name) + @klass.name, @klass.primary_key) end # Gives a record (or N records if a parameter is supplied) without any implied @@ -145,10 +148,9 @@ module ActiveRecord # # [#<Person id:4>, #<Person id:3>, #<Person id:2>] def last(limit = nil) - return find_last(limit) if loaded? || limit_value + return find_last(limit) if loaded? || has_limit_or_offset? - result = limit(limit) - result.order!(arel_attribute(primary_key)) if order_values.empty? && primary_key + result = ordered_relation.limit(limit) result = result.reverse_order! limit ? result.reverse : result.first @@ -283,7 +285,7 @@ module ActiveRecord # * Hash - Finds the record that matches these +find+-style conditions # (such as <tt>{name: 'David'}</tt>). # * +false+ - Returns always +false+. - # * No args - Returns +false+ if the table is empty, +true+ otherwise. + # * No args - Returns +false+ if the relation is empty, +true+ otherwise. # # For more information about specifying conditions as a hash or array, # see the Conditions section in the introduction to ActiveRecord::Base. @@ -299,6 +301,7 @@ module ActiveRecord # Person.exists?(name: 'David') # Person.exists?(false) # Person.exists? + # Person.where(name: 'Spartacus', rating: 4).exists? def exists?(conditions = :none) if Base === conditions raise ArgumentError, <<-MSG.squish @@ -307,23 +310,16 @@ module ActiveRecord MSG end - return false if !conditions - - relation = apply_join_dependency(self, construct_join_dependency(eager_loading: false)) - return false if ActiveRecord::NullRelation === relation + return false if !conditions || limit_value == 0 - relation = relation.except(:select, :distinct).select(ONE_AS_ONE).limit(1) - - case conditions - when Array, Hash - relation = relation.where(conditions) - else - unless conditions == :none - relation = relation.where(primary_key => conditions) - end + if eager_loading? + relation = apply_join_dependency(eager_loading: false) + return relation.exists?(conditions) end - connection.select_value(relation, "#{name} Exists", relation.bound_attributes) ? true : false + relation = construct_relation_for_exists(conditions) + + skip_query_cache_if_necessary { connection.select_value(relation.arel, "#{name} Exists") } ? true : false rescue ::RangeError false end @@ -336,23 +332,23 @@ module ActiveRecord # of results obtained should be provided in the +result_size+ argument and # the expected number of results should be provided in the +expected_size+ # argument. - def raise_record_not_found_exception!(ids = nil, result_size = nil, expected_size = nil, key = primary_key) # :nodoc: - conditions = arel.where_sql(@klass.arel_engine) + def raise_record_not_found_exception!(ids = nil, result_size = nil, expected_size = nil, key = primary_key, not_found_ids = nil) # :nodoc: + conditions = arel.where_sql(@klass) conditions = " [#{conditions}]" if conditions name = @klass.name if ids.nil? - error = "Couldn't find #{name}" + error = "Couldn't find #{name}".dup error << " with#{conditions}" if conditions - raise RecordNotFound.new(error, name) + raise RecordNotFound.new(error, name, key) elsif Array(ids).size == 1 error = "Couldn't find #{name} with '#{key}'=#{ids}#{conditions}" raise RecordNotFound.new(error, name, key, ids) else - error = "Couldn't find all #{name.pluralize} with '#{key}': " - error << "(#{ids.join(", ")})#{conditions} (found #{result_size} results, but was looking for #{expected_size})" - - raise RecordNotFound.new(error, name, primary_key, ids) + error = "Couldn't find all #{name.pluralize} with '#{key}': ".dup + error << "(#{ids.join(", ")})#{conditions} (found #{result_size} results, but was looking for #{expected_size})." + error << " Couldn't find #{name.pluralize(not_found_ids.size)} with #{key.to_s.pluralize(not_found_ids.size)} #{not_found_ids.join(', ')}." if not_found_ids + raise RecordNotFound.new(error, name, key, ids) end end @@ -362,67 +358,53 @@ module ActiveRecord offset_value || 0 end - def find_with_associations - # NOTE: the JoinDependency constructed here needs to know about - # any joins already present in `self`, so pass them in - # - # failing to do so means that in cases like activerecord/test/cases/associations/inner_join_association_test.rb:136 - # incorrect SQL is generated. In that case, the join dependency for - # SpecialCategorizations is constructed without knowledge of the - # preexisting join in joins_values to categorizations (by way of - # the `has_many :through` for categories). - # - join_dependency = construct_join_dependency(joins_values) - - aliases = join_dependency.aliases - relation = select aliases.columns - relation = apply_join_dependency(relation, join_dependency) + def construct_relation_for_exists(conditions) + relation = except(:select, :distinct, :order)._select!(ONE_AS_ONE).limit!(1) - if block_given? - yield relation + case conditions + when Array, Hash + relation.where!(conditions) else - if ActiveRecord::NullRelation === relation - [] - else - arel = relation.arel - rows = connection.select_all(arel, "SQL", relation.bound_attributes) - join_dependency.instantiate(rows, aliases) - end + relation.where!(primary_key => conditions) unless conditions == :none end - end - def construct_join_dependency(joins = [], eager_loading: true) - including = eager_load_values + includes_values - ActiveRecord::Associations::JoinDependency.new(@klass, including, joins, eager_loading: eager_loading) + relation end - def construct_relation_for_association_calculations - apply_join_dependency(self, construct_join_dependency(joins_values)) + def construct_join_dependency(associations) + ActiveRecord::Associations::JoinDependency.new( + klass, table, associations + ) end - def apply_join_dependency(relation, join_dependency) - relation = relation.except(:includes, :eager_load, :preload) - relation = relation.joins join_dependency + 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) - if using_limitable_reflections?(join_dependency.reflections) - relation - else - if relation.limit_value + if eager_loading && !using_limitable_reflections?(join_dependency.reflections) + if has_limit_or_offset? limited_ids = limited_ids_for(relation) limited_ids.empty? ? relation.none! : relation.where!(primary_key => limited_ids) end - relation.except(:limit, :offset) + relation.limit_value = relation.offset_value = nil + end + + if block_given? + yield relation, join_dependency + else + relation end end def limited_ids_for(relation) values = @klass.connection.columns_for_distinct( - "#{quoted_table_name}.#{quoted_primary_key}", relation.order_values) + connection.visitor.compile(arel_attribute(primary_key)), + relation.order_values + ) relation = relation.except(:select).select(values).distinct! - arel = relation.arel - id_rows = @klass.connection.select_all(arel, "SQL", relation.bound_attributes) + id_rows = skip_query_cache_if_necessary { @klass.connection.select_all(relation.arel, "SQL") } id_rows.map { |row| row[primary_key] } end @@ -438,9 +420,12 @@ module ActiveRecord ids = ids.flatten.compact.uniq + model_name = @klass.name + case ids.size when 0 - raise RecordNotFound, "Couldn't find #{@klass.name} without an ID" + error_message = "Couldn't find #{model_name} without an ID" + raise RecordNotFound.new(error_message, model_name, primary_key) when 1 result = find_one(ids.first) expects_array ? [ result ] : result @@ -448,7 +433,8 @@ module ActiveRecord find_some(ids) end rescue ::RangeError - raise RecordNotFound, "Couldn't find #{@klass.name} with an out of range ID" + error_message = "Couldn't find #{model_name} with an out of range ID" + raise RecordNotFound.new(error_message, model_name, primary_key, ids) end def find_one(id) @@ -530,13 +516,13 @@ module ActiveRecord if loaded? records[index, limit] || [] else - relation = if order_values.empty? && primary_key - order(arel_attribute(primary_key).asc) - else - self + relation = ordered_relation + + if limit_value + limit = [limit_value - index, limit].min end - if limit_value.nil? || index < limit_value + if limit > 0 relation = relation.offset(offset_index + index) unless index.zero? relation.limit(limit).to_a else @@ -549,23 +535,26 @@ module ActiveRecord if loaded? records[-index] else - relation = if order_values.empty? && primary_key - order(arel_attribute(primary_key).asc) + relation = ordered_relation + + if equal?(relation) || has_limit_or_offset? + relation.records[-index] else - self + relation.last(index)[-index] end - - relation.to_a[-index] - # TODO: can be made more performant on large result sets by - # for instance, last(index)[-index] (which would require - # refactoring the last(n) finder method to make test suite pass), - # or by using a combination of reverse_order, limit, and offset, - # e.g., reverse_order.offset(index-1).first end end def find_last(limit) limit ? records.last(limit) : records.last end + + def ordered_relation + if order_values.empty? && primary_key + order(arel_attribute(primary_key).asc) + else + self + end + end end end diff --git a/activerecord/lib/active_record/relation/from_clause.rb b/activerecord/lib/active_record/relation/from_clause.rb index 8945cb0cc5..c53a682aee 100644 --- a/activerecord/lib/active_record/relation/from_clause.rb +++ b/activerecord/lib/active_record/relation/from_clause.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class Relation class FromClause # :nodoc: @@ -8,14 +10,6 @@ module ActiveRecord @name = name end - def binds - if value.is_a?(Relation) - value.bound_attributes - else - [] - end - end - def merge(other) self end diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index 5dac00724a..07b16a0740 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/hash/keys" module ActiveRecord @@ -21,7 +23,11 @@ module ActiveRecord # build a relation to merge in rather than directly merging # the values. def other - other = Relation.create(relation.klass, relation.table, relation.predicate_builder) + other = Relation.create( + relation.klass, + table: relation.table, + predicate_builder: relation.predicate_builder + ) hash.each { |k, v| if k == :joins if Hash === v @@ -50,7 +56,7 @@ module ActiveRecord NORMAL_VALUES = Relation::VALUE_METHODS - Relation::CLAUSE_METHODS - - [:includes, :preload, :joins, :order, :reverse_order, :lock, :create_with, :reordering] # :nodoc: + [:includes, :preload, :joins, :left_outer_joins, :order, :reverse_order, :lock, :create_with, :reordering] # :nodoc: def normal_values NORMAL_VALUES @@ -77,6 +83,7 @@ module ActiveRecord merge_clauses merge_preloads merge_joins + merge_outer_joins relation end @@ -110,41 +117,53 @@ module ActiveRecord if other.klass == relation.klass relation.joins!(*other.joins_values) else - joins_dependency, rest = other.joins_values.partition do |join| + joins_dependency = other.joins_values.map do |join| case join when Hash, Symbol, Array - true + other.send(:construct_join_dependency, join) else - false + join end end - join_dependency = ActiveRecord::Associations::JoinDependency.new(other.klass, - joins_dependency, - []) - relation.joins! rest + relation.joins!(*joins_dependency) + end + end - @relation = relation.joins join_dependency + def merge_outer_joins + return if other.left_outer_joins_values.blank? + + 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) end end def merge_multi_values if other.reordering_value # override any order specified in the original relation - relation.reorder! other.order_values - elsif other.order_values + relation.reorder!(*other.order_values) + elsif other.order_values.any? # merge in order_values from relation - relation.order! other.order_values + relation.order!(*other.order_values) end - relation.extend(*other.extending_values) unless other.extending_values.blank? + extensions = other.extensions - relation.extensions + relation.extending!(*extensions) if extensions.any? end def merge_single_values - if relation.from_clause.empty? - relation.from_clause = other.from_clause - end - relation.lock_value ||= other.lock_value + relation.lock_value ||= other.lock_value if other.lock_value unless other.create_with_value.blank? relation.create_with_value = (relation.create_with_value || {}).merge(other.create_with_value) @@ -152,11 +171,15 @@ module ActiveRecord end def merge_clauses - CLAUSE_METHODS.each do |method| - clause = relation.get_value(method) - other_clause = other.get_value(method) - relation.set_value(method, clause.merge(other_clause)) + if relation.from_clause.empty? && !other.from_clause.empty? + relation.from_clause = other.from_clause end + + where_clause = relation.where_clause.merge(other.where_clause) + relation.where_clause = where_clause unless where_clause.empty? + + having_clause = relation.having_clause.merge(other.having_clause) + relation.having_clause = having_clause unless having_clause.empty? end end end diff --git a/activerecord/lib/active_record/relation/predicate_builder.rb b/activerecord/lib/active_record/relation/predicate_builder.rb index 18ae10a652..f734cd0ad8 100644 --- a/activerecord/lib/active_record/relation/predicate_builder.rb +++ b/activerecord/lib/active_record/relation/predicate_builder.rb @@ -1,27 +1,19 @@ +# frozen_string_literal: true + module ActiveRecord class PredicateBuilder # :nodoc: - require "active_record/relation/predicate_builder/array_handler" - require "active_record/relation/predicate_builder/association_query_handler" - require "active_record/relation/predicate_builder/base_handler" - require "active_record/relation/predicate_builder/basic_object_handler" - require "active_record/relation/predicate_builder/polymorphic_array_handler" - require "active_record/relation/predicate_builder/range_handler" - require "active_record/relation/predicate_builder/relation_handler" - delegate :resolve_column_aliases, to: :table def initialize(table) @table = table @handlers = [] - register_handler(BasicObject, BasicObjectHandler.new) + register_handler(BasicObject, BasicObjectHandler.new(self)) register_handler(Base, BaseHandler.new(self)) - register_handler(Range, RangeHandler.new) - register_handler(RangeHandler::RangeWithBinds, RangeHandler.new) + register_handler(Range, RangeHandler.new(self)) register_handler(Relation, RelationHandler.new) register_handler(Array, ArrayHandler.new(self)) - register_handler(AssociationQueryValue, AssociationQueryHandler.new(self)) - register_handler(PolymorphicArrayValue, PolymorphicArrayHandler.new(self)) + register_handler(Set, ArrayHandler.new(self)) end def build_from_hash(attributes) @@ -29,11 +21,6 @@ module ActiveRecord expand_from_hash(attributes) end - def create_binds(attributes) - attributes = convert_dot_notation_to_hash(attributes) - create_binds_for_hash(attributes) - end - def self.references(attributes) attributes.map do |key, value| if value.is_a?(Hash) @@ -61,74 +48,66 @@ module ActiveRecord end def build(attribute, value) - handler_for(value).call(attribute, value) + if table.type(attribute.name).force_equality?(value) + bind = build_bind_attribute(attribute.name, value) + attribute.eq(bind) + else + handler_for(value).call(attribute, value) + end end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :table + def build_bind_attribute(column_name, value) + attr = Relation::QueryAttribute.new(column_name.to_s, value, table.type(column_name)) + Arel::Nodes::BindParam.new(attr) + end + protected def expand_from_hash(attributes) return ["1=0"] if attributes.empty? attributes.flat_map do |key, value| if value.is_a?(Hash) && !table.has_column?(key) associated_predicate_builder(key).expand_from_hash(value) - else - build(table.arel_attribute(key), value) - end - end - end + elsif table.associated_with?(key) + # Find the foreign key when using queries such as: + # Post.where(author: author) + # + # For polymorphic relationships, find the foreign key and type: + # PriceEstimate.where(estimate_of: treasure) + associated_table = table.associated_table(key) + if associated_table.polymorphic_association? + case value.is_a?(Array) ? value.first : value + when Base, Relation + value = [value] unless value.is_a?(Array) + klass = PolymorphicArrayValue + end + end - def create_binds_for_hash(attributes) - result = attributes.dup - binds = [] - - attributes.each do |column_name, value| - case - when value.is_a?(Hash) && !table.has_column?(column_name) - attrs, bvs = associated_predicate_builder(column_name).create_binds_for_hash(value) - result[column_name] = attrs - binds += bvs - next - when value.is_a?(Relation) - binds += value.bound_attributes - when value.is_a?(Range) && !table.type(column_name).respond_to?(:subtype) - first = value.begin - last = value.end - unless first.respond_to?(:infinite?) && first.infinite? - binds << build_bind_param(column_name, first) - first = Arel::Nodes::BindParam.new + klass ||= AssociationQueryValue + queries = klass.new(associated_table, value).queries.map do |query| + expand_from_hash(query).reduce(&:and) end - unless last.respond_to?(:infinite?) && last.infinite? - binds << build_bind_param(column_name, last) - last = Arel::Nodes::BindParam.new + queries.reduce(&:or) + elsif table.aggregated_with?(key) + mapping = table.reflect_on_aggregation(key).mapping + queries = Array.wrap(value).map do |object| + mapping.map do |field_attr, aggregate_attr| + if mapping.size == 1 && !object.respond_to?(aggregate_attr) + build(table.arel_attribute(field_attr), object) + else + build(table.arel_attribute(field_attr), object.send(aggregate_attr)) + end + end.reduce(&:and) end - - result[column_name] = RangeHandler::RangeWithBinds.new(first, last, value.exclude_end?) + queries.reduce(&:or) else - if can_be_bound?(column_name, value) - result[column_name] = Arel::Nodes::BindParam.new - binds << build_bind_param(column_name, value) - end - end - - # Find the foreign key when using queries such as: - # Post.where(author: author) - # - # For polymorphic relationships, find the foreign key and type: - # PriceEstimate.where(estimate_of: treasure) - if table.associated_with?(column_name) - result[column_name] = AssociationQueryHandler.value_for(table, column_name, value) + build(table.arel_attribute(key), value) end end - - [result, binds] end private + attr_reader :table def associated_predicate_builder(association_name) self.class.new(table.associated_table(association_name)) @@ -153,19 +132,14 @@ module ActiveRecord def handler_for(object) @handlers.detect { |klass, _| klass === object }.last end - - def can_be_bound?(column_name, value) - return if table.associated_with?(column_name) - case value - when Array, Range - table.type(column_name).respond_to?(:subtype) - else - !value.nil? && handler_for(value).is_a?(BasicObjectHandler) - end - end - - def build_bind_param(column_name, value) - Relation::QueryAttribute.new(column_name.to_s, value, table.type(column_name)) - end end end + +require "active_record/relation/predicate_builder/array_handler" +require "active_record/relation/predicate_builder/base_handler" +require "active_record/relation/predicate_builder/basic_object_handler" +require "active_record/relation/predicate_builder/range_handler" +require "active_record/relation/predicate_builder/relation_handler" + +require "active_record/relation/predicate_builder/association_query_value" +require "active_record/relation/predicate_builder/polymorphic_array_value" diff --git a/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb index 88b6c37d43..e5191fa38a 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/extract" + module ActiveRecord class PredicateBuilder class ArrayHandler # :nodoc: @@ -6,18 +10,21 @@ module ActiveRecord end def call(attribute, value) - values = value.map { |x| x.is_a?(Base) ? x.id : x } - nils, values = values.partition(&:nil?) + return attribute.in([]) if value.empty? - return attribute.in([]) if values.empty? && nils.empty? - - ranges, values = values.partition { |v| v.is_a?(Range) } + values = value.map { |x| x.is_a?(Base) ? x.id : x } + nils = values.extract!(&:nil?) + ranges = values.extract! { |v| v.is_a?(Range) } values_predicate = case values.length when 0 then NullPredicate when 1 then predicate_builder.build(attribute, values.first) - else attribute.in(values) + else + bind_values = values.map do |v| + predicate_builder.build_bind_attribute(attribute.name, v) + end + attribute.in(bind_values) end unless nils.empty? @@ -26,13 +33,10 @@ module ActiveRecord array_predicates = ranges.map { |range| predicate_builder.build(attribute, range) } array_predicates.unshift(values_predicate) - array_predicates.inject { |composite, predicate| composite.or(predicate) } + array_predicates.inject(&:or) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :predicate_builder module NullPredicate # :nodoc: diff --git a/activerecord/lib/active_record/relation/predicate_builder/association_query_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/association_query_handler.rb deleted file mode 100644 index 29860ec677..0000000000 --- a/activerecord/lib/active_record/relation/predicate_builder/association_query_handler.rb +++ /dev/null @@ -1,88 +0,0 @@ -module ActiveRecord - class PredicateBuilder - class AssociationQueryHandler # :nodoc: - def self.value_for(table, column, value) - associated_table = table.associated_table(column) - klass = if associated_table.polymorphic_association? && ::Array === value && value.first.is_a?(Base) - PolymorphicArrayValue - else - AssociationQueryValue - end - - klass.new(associated_table, value) - end - - def initialize(predicate_builder) - @predicate_builder = predicate_builder - end - - def call(attribute, value) - queries = {} - - table = value.associated_table - if value.base_class - queries[table.association_foreign_type.to_s] = value.base_class.name - end - - queries[table.association_foreign_key.to_s] = value.ids - predicate_builder.build_from_hash(queries) - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :predicate_builder - end - - class AssociationQueryValue # :nodoc: - attr_reader :associated_table, :value - - def initialize(associated_table, value) - @associated_table = associated_table - @value = value - end - - def ids - case value - when Relation - value.select(primary_key) - when Array - value.map { |v| convert_to_id(v) } - else - convert_to_id(value) - end - end - - def base_class - if associated_table.polymorphic_association? - @base_class ||= polymorphic_base_class_from_value - end - end - - private - - def primary_key - associated_table.association_primary_key(base_class) - end - - def polymorphic_base_class_from_value - case value - when Relation - value.klass.base_class - when Base - value.class.base_class - end - end - - def convert_to_id(value) - case value - when Base - value._read_attribute(primary_key) - else - value - end - end - end - end -end diff --git a/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb b/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb new file mode 100644 index 0000000000..88cd71cf69 --- /dev/null +++ b/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ActiveRecord + class PredicateBuilder + class AssociationQueryValue # :nodoc: + def initialize(associated_table, value) + @associated_table = associated_table + @value = value + end + + def queries + [associated_table.association_join_foreign_key.to_s => ids] + end + + private + attr_reader :associated_table, :value + + def ids + case value + when Relation + value.select_values.empty? ? value.select(primary_key) : value + when Array + value.map { |v| convert_to_id(v) } + else + convert_to_id(value) + end + end + + def primary_key + associated_table.association_join_primary_key + end + + def convert_to_id(value) + case value + when Base + value._read_attribute(primary_key) + else + value + end + end + end + end +end diff --git a/activerecord/lib/active_record/relation/predicate_builder/base_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/base_handler.rb index 3bb1037885..10c5c1a66a 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/base_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/base_handler.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class PredicateBuilder class BaseHandler # :nodoc: @@ -9,10 +11,7 @@ module ActiveRecord predicate_builder.build(attribute, value.id) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :predicate_builder end end diff --git a/activerecord/lib/active_record/relation/predicate_builder/basic_object_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/basic_object_handler.rb index 79cde00303..e8c9f60860 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/basic_object_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/basic_object_handler.rb @@ -1,9 +1,19 @@ +# frozen_string_literal: true + module ActiveRecord class PredicateBuilder class BasicObjectHandler # :nodoc: + def initialize(predicate_builder) + @predicate_builder = predicate_builder + end + def call(attribute, value) - attribute.eq(value) + bind = predicate_builder.build_bind_attribute(attribute.name, value) + attribute.eq(bind) end + + private + attr_reader :predicate_builder end end end diff --git a/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_handler.rb deleted file mode 100644 index 335124c952..0000000000 --- a/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_handler.rb +++ /dev/null @@ -1,59 +0,0 @@ -module ActiveRecord - class PredicateBuilder - class PolymorphicArrayHandler # :nodoc: - def initialize(predicate_builder) - @predicate_builder = predicate_builder - end - - def call(attribute, value) - table = value.associated_table - queries = value.type_to_ids_mapping.map do |type, ids| - { table.association_foreign_type.to_s => type, table.association_foreign_key.to_s => ids } - end - - predicates = queries.map { |query| predicate_builder.build_from_hash(query) } - - if predicates.size > 1 - type_and_ids_predicates = predicates.map { |type_predicate, id_predicate| Arel::Nodes::Grouping.new(type_predicate.and(id_predicate)) } - type_and_ids_predicates.inject(&:or) - else - predicates.first - end - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :predicate_builder - end - - class PolymorphicArrayValue # :nodoc: - attr_reader :associated_table, :values - - def initialize(associated_table, values) - @associated_table = associated_table - @values = values - end - - def type_to_ids_mapping - default_hash = Hash.new { |hsh, key| hsh[key] = [] } - values.each_with_object(default_hash) { |value, hash| hash[base_class(value).name] << convert_to_id(value) } - end - - private - - def primary_key(value) - associated_table.association_primary_key(base_class(value)) - end - - def base_class(value) - value.class.base_class - end - - def convert_to_id(value) - value._read_attribute(primary_key(value)) - end - end - end -end diff --git a/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb b/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb new file mode 100644 index 0000000000..aae04d9348 --- /dev/null +++ b/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module ActiveRecord + class PredicateBuilder + class PolymorphicArrayValue # :nodoc: + def initialize(associated_table, values) + @associated_table = associated_table + @values = values + end + + def queries + type_to_ids_mapping.map do |type, ids| + { + associated_table.association_foreign_type.to_s => type, + associated_table.association_foreign_key.to_s => ids + } + end + end + + private + attr_reader :associated_table, :values + + def type_to_ids_mapping + default_hash = Hash.new { |hsh, key| hsh[key] = [] } + values.each_with_object(default_hash) do |value, hash| + hash[klass(value).polymorphic_name] << convert_to_id(value) + end + end + + def primary_key(value) + associated_table.association_join_primary_key(klass(value)) + end + + def klass(value) + case value + when Base + value.class + when Relation + value.klass + end + end + + def convert_to_id(value) + case value + when Base + value._read_attribute(primary_key(value)) + when Relation + value.select(primary_key(value)) + end + end + end + end +end diff --git a/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb index 5db778e19c..44bb2c7ab6 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb @@ -1,25 +1,41 @@ +# frozen_string_literal: true + module ActiveRecord class PredicateBuilder class RangeHandler # :nodoc: - RangeWithBinds = Struct.new(:begin, :end, :exclude_end?) + class RangeWithBinds < Struct.new(:begin, :end) + def exclude_end? + false + end + end + + def initialize(predicate_builder) + @predicate_builder = predicate_builder + end def call(attribute, value) - if value.begin.respond_to?(:infinite?) && value.begin.infinite? - if value.end.respond_to?(:infinite?) && value.end.infinite? + begin_bind = predicate_builder.build_bind_attribute(attribute.name, value.begin) + end_bind = predicate_builder.build_bind_attribute(attribute.name, value.end) + + if begin_bind.value.infinity? + if end_bind.value.infinity? attribute.not_in([]) elsif value.exclude_end? - attribute.lt(value.end) + attribute.lt(end_bind) else - attribute.lteq(value.end) + attribute.lteq(end_bind) end - elsif value.end.respond_to?(:infinite?) && value.end.infinite? - attribute.gteq(value.begin) + elsif end_bind.value.infinity? + attribute.gteq(begin_bind) elsif value.exclude_end? - attribute.gteq(value.begin).and(attribute.lt(value.end)) + attribute.gteq(begin_bind).and(attribute.lt(end_bind)) else - attribute.between(value) + attribute.between(RangeWithBinds.new(begin_bind, end_bind)) end end + + private + attr_reader :predicate_builder end end end diff --git a/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb index 8a910a82fe..c8bbfa5051 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb @@ -1,7 +1,13 @@ +# frozen_string_literal: true + module ActiveRecord class PredicateBuilder class RelationHandler # :nodoc: def call(attribute, value) + if value.eager_loading? + value = value.send(:apply_join_dependency) + end + if value.select_values.empty? value = value.select(value.arel_attribute(value.klass.primary_key)) end diff --git a/activerecord/lib/active_record/relation/query_attribute.rb b/activerecord/lib/active_record/relation/query_attribute.rb index a68e508fcc..f64bd30d38 100644 --- a/activerecord/lib/active_record/relation/query_attribute.rb +++ b/activerecord/lib/active_record/relation/query_attribute.rb @@ -1,8 +1,10 @@ -require "active_record/attribute" +# frozen_string_literal: true + +require "active_model/attribute" module ActiveRecord class Relation - class QueryAttribute < Attribute # :nodoc: + class QueryAttribute < ActiveModel::Attribute # :nodoc: def type_cast(value) value end @@ -14,6 +16,28 @@ module ActiveRecord def with_cast_value(value) QueryAttribute.new(name, value, type) end + + def nil? + !value_before_type_cast.is_a?(StatementCache::Substitute) && + (value_before_type_cast.nil? || value_for_database.nil?) + end + + def boundable? + return @_boundable if defined?(@_boundable) + nil? + @_boundable = true + rescue ::RangeError + @_boundable = false + end + + def infinity? + _infinity?(value_before_type_cast) || boundable? && _infinity?(value_for_database) + end + + private + def _infinity?(value) + value.respond_to?(:infinite?) && value.infinite? + end end end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 4ee413c805..56497e11cb 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/relation/from_clause" require "active_record/relation/query_attribute" require "active_record/relation/where_clause" @@ -74,31 +76,6 @@ module ActiveRecord CODE end - def bound_attributes - if limit_value - limit_bind = Attribute.with_cast_value( - "LIMIT".freeze, - connection.sanitize_limit(limit_value), - Type.default_value, - ) - end - if offset_value - offset_bind = Attribute.with_cast_value( - "OFFSET".freeze, - offset_value.to_i, - Type.default_value, - ) - end - connection.combine_bind_parameters( - from_clause: from_clause.binds, - join_clause: arel.bind_values, - where_clause: where_clause.binds, - having_clause: having_clause.binds, - limit: limit_bind, - offset: offset_bind, - ) - end - alias extensions extending_values # Specify relationships to be included in the result set. For @@ -202,12 +179,13 @@ module ActiveRecord # Works in two unique ways. # - # First: takes a block so it can be used just like +Array#select+. + # First: takes a block so it can be used just like <tt>Array#select</tt>. # # Model.all.select { |m| m.field == value } # # This will build an array of objects from the database for the scope, - # converting them into an array and iterating through them using +Array#select+. + # converting them into an array and iterating through them using + # <tt>Array#select</tt>. # # Second: Modifies the SELECT statement for the query so that only certain # fields are retrieved: @@ -248,11 +226,12 @@ module ActiveRecord return super() end - raise ArgumentError, "Call this with at least one field" if fields.empty? + raise ArgumentError, "Call `select' with at least one field" if fields.empty? spawn._select!(*fields) end def _select!(*fields) # :nodoc: + fields.reject!(&:blank?) fields.flatten! fields.map! do |field| klass.attribute_alias?(field) ? klass.attribute_alias(field).to_sym : field @@ -317,6 +296,7 @@ module ActiveRecord spawn.order!(*args) end + # Same as #order but operates on relation in-place instead of copying. def order!(*args) # :nodoc: preprocess_order_args(args) @@ -338,6 +318,7 @@ module ActiveRecord spawn.reorder!(*args) end + # Same as #reorder but operates on relation in-place instead of copying. def reorder!(*args) # :nodoc: preprocess_order_args(args) @@ -347,8 +328,8 @@ module ActiveRecord end VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock, - :limit, :offset, :joins, :includes, :from, - :readonly, :having]) + :limit, :offset, :joins, :left_outer_joins, + :includes, :from, :readonly, :having]) # Removes an unwanted relation that is already defined on a chain of relations. # This is useful when passing around chains of relations and would like to @@ -395,10 +376,11 @@ module ActiveRecord args.each do |scope| case scope when Symbol + scope = :left_outer_joins if scope == :left_joins 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, nil) + set_value(scope, DEFAULT_VALUES[scope]) when Hash scope.each do |key, target_value| if key != :where @@ -463,16 +445,14 @@ module ActiveRecord # => SELECT "users".* FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" # def left_outer_joins(*args) - check_if_method_has_arguments!(:left_outer_joins, args) - - args.compact! - args.flatten! - + check_if_method_has_arguments!(__callee__, args) spawn.left_outer_joins!(*args) end alias :left_joins :left_outer_joins def left_outer_joins!(*args) # :nodoc: + args.compact! + args.flatten! self.left_outer_joins_values += args self end @@ -657,6 +637,7 @@ module ActiveRecord self.where_clause = self.where_clause.or(other.where_clause) self.having_clause = having_clause.or(other.having_clause) + self.references_values += other.references_values self end @@ -797,7 +778,7 @@ module ActiveRecord value = sanitize_forbidden_attributes(value) self.create_with_value = create_with_value.merge(value) else - self.create_with_value = {} + self.create_with_value = FROZEN_EMPTY_HASH end self @@ -913,21 +894,33 @@ module ActiveRecord self end + def skip_query_cache!(value = true) # :nodoc: + self.skip_query_cache_value = value + self + end + + def skip_preloading! # :nodoc: + self.skip_preloading_value = true + self + end + # Returns the Arel object associated with the relation. - def arel # :nodoc: - @arel ||= build_arel + def arel(aliases = nil) # :nodoc: + @arel ||= build_arel(aliases) end # Returns a relation value with a given name def get_value(name) # :nodoc: - @values[name] || default_value_for(name) + @values.fetch(name, DEFAULT_VALUES[name]) end - # Sets the relation value with the given name - def set_value(name, value) # :nodoc: - assert_mutability! - @values[name] = value - end + protected + + # Sets the relation value with the given name + def set_value(name, value) # :nodoc: + assert_mutability! + @values[name] = value + end private @@ -936,16 +929,30 @@ module ActiveRecord raise ImmutableRelation if defined?(@arel) && @arel end - def build_arel + def build_arel(aliases) arel = Arel::SelectManager.new(table) - build_joins(arel, joins_values.flatten) unless joins_values.empty? - build_left_outer_joins(arel, left_outer_joins_values.flatten) unless left_outer_joins_values.empty? + 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? arel.where(where_clause.ast) unless where_clause.empty? arel.having(having_clause.ast) unless having_clause.empty? - arel.take(Arel::Nodes::BindParam.new) if limit_value - arel.skip(Arel::Nodes::BindParam.new) if offset_value + if limit_value + limit_attribute = ActiveModel::Attribute.with_cast_value( + "LIMIT".freeze, + connection.sanitize_limit(limit_value), + Type.default_value, + ) + arel.take(Arel::Nodes::BindParam.new(limit_attribute)) + end + if offset_value + offset_attribute = ActiveModel::Attribute.with_cast_value( + "OFFSET".freeze, + offset_value.to_i, + Type.default_value, + ) + arel.skip(Arel::Nodes::BindParam.new(offset_attribute)) + end arel.group(*arel_columns(group_values.uniq.reject(&:blank?))) unless group_values.empty? build_order(arel) @@ -964,6 +971,9 @@ module ActiveRecord name = from_clause.name case opts when Relation + if opts.eager_loading? + opts = opts.send(:apply_join_dependency) + end name ||= "subquery" opts.arel.as(name.to_s) else @@ -971,20 +981,22 @@ module ActiveRecord end end - def build_left_outer_joins(manager, outer_joins) + def build_left_outer_joins(manager, outer_joins, aliases) buckets = outer_joins.group_by do |join| case join when Hash, Symbol, Array :association_join + when ActiveRecord::Associations::JoinDependency + :stashed_join else raise ArgumentError, "only Hash, Symbol and Array are allowed" end end - build_join_query(manager, buckets, Arel::Nodes::OuterJoin) + build_join_query(manager, buckets, Arel::Nodes::OuterJoin, aliases) end - def build_joins(manager, joins) + def build_joins(manager, joins, aliases) buckets = joins.group_by do |join| case join when String @@ -1000,38 +1012,31 @@ module ActiveRecord end end - build_join_query(manager, buckets, Arel::Nodes::InnerJoin) + build_join_query(manager, buckets, Arel::Nodes::InnerJoin, aliases) end - def build_join_query(manager, buckets, join_type) + def build_join_query(manager, buckets, join_type, aliases) buckets.default = [] - association_joins = buckets[:association_join] - stashed_association_joins = buckets[:stashed_join] - join_nodes = buckets[:join_node].uniq - string_joins = buckets[:string_join].map(&:strip).uniq + association_joins = buckets[:association_join] + stashed_joins = buckets[:stashed_join] + join_nodes = buckets[:join_node].uniq + string_joins = buckets[:string_join].map(&:strip).uniq - join_list = join_nodes + convert_join_strings_to_ast(manager, string_joins) + join_list = join_nodes + convert_join_strings_to_ast(string_joins) + alias_tracker = alias_tracker(join_list, aliases) - join_dependency = ActiveRecord::Associations::JoinDependency.new( - @klass, - association_joins, - join_list - ) + join_dependency = construct_join_dependency(association_joins) - join_infos = join_dependency.join_constraints stashed_association_joins, join_type - - join_infos.each do |info| - info.joins.each { |join| manager.from(join) } - manager.bind_values.concat info.binds - end + joins = join_dependency.join_constraints(stashed_joins, join_type, alias_tracker) + joins.each { |join| manager.from(join) } manager.join_sources.concat(join_list) - manager + alias_tracker.aliases end - def convert_join_strings_to_ast(table, joins) + def convert_join_strings_to_ast(joins) joins .flatten .reject(&:blank?) @@ -1041,17 +1046,21 @@ module ActiveRecord def build_select(arel) if select_values.any? arel.project(*arel_columns(select_values.uniq)) + elsif klass.ignored_columns.any? + arel.project(*klass.column_names.map { |field| arel_attribute(field) }) else - arel.project(@klass.arel_table[Arel.star]) + arel.project(table[Arel.star]) end end def arel_columns(columns) - columns.map do |field| + columns.flat_map do |field| if (Symbol === field || String === field) && (klass.has_attribute?(field) || klass.attribute_alias?(field)) && !from_clause.value arel_attribute(field) elsif Symbol === field connection.quote_table_name(field.to_s) + elsif Proc === field + field.call else field end @@ -1077,7 +1086,7 @@ module ActiveRecord end o.split(",").map! do |s| s.strip! - s.gsub!(/\sasc\Z/i, " DESC") || s.gsub!(/\sdesc\Z/i, " ASC") || s.concat(" DESC") + s.gsub!(/\sasc\Z/i, " DESC") || s.gsub!(/\sdesc\Z/i, " ASC") || (s << " DESC") end else o @@ -1086,6 +1095,10 @@ module ActiveRecord end def does_not_support_reverse?(order) + # Account for String subclasses like Arel::Nodes::SqlLiteral that + # override methods like #count. + order = String.new(order) unless order.instance_of?(String) + # Uses SQL function with multiple arguments. (order.include?(",") && order.split(",").find { |section| section.count("(") != section.count(")") }) || # Uses "nulls first" like construction. @@ -1100,27 +1113,35 @@ module ActiveRecord end VALID_DIRECTIONS = [:asc, :desc, :ASC, :DESC, - "asc", "desc", "ASC", "DESC"] # :nodoc: + "asc", "desc", "ASC", "DESC"].to_set # :nodoc: def validate_order_args(args) args.each do |arg| next unless arg.is_a?(Hash) arg.each do |_key, value| - raise ArgumentError, "Direction \"#{value}\" is invalid. Valid " \ - "directions are: #{VALID_DIRECTIONS.inspect}" unless VALID_DIRECTIONS.include?(value) + unless VALID_DIRECTIONS.include?(value) + raise ArgumentError, + "Direction \"#{value}\" is invalid. Valid directions are: #{VALID_DIRECTIONS.to_a.inspect}" + end end end end def preprocess_order_args(order_args) order_args.map! do |arg| - klass.send(:sanitize_sql_for_order, arg) + klass.sanitize_sql_for_order(arg) end order_args.flatten! + + @klass.disallow_raw_sql!( + order_args.flat_map { |a| a.is_a?(Hash) ? a.keys : a }, + permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER + ) + validate_order_args(order_args) references = order_args.grep(String) - references.map! { |arg| arg =~ /^([a-zA-Z]\w*)\.(\w+)/ && $1 }.compact! + references.map! { |arg| arg =~ /^\W?(\w+)\W?\./ && $1 }.compact! references!(references) if references.any? # if a symbol is given we prepend the quoted table name @@ -1130,7 +1151,12 @@ module ActiveRecord arel_attribute(arg).asc when Hash arg.map { |field, dir| - arel_attribute(field).send(dir.downcase) + case field + when Arel::Nodes::SqlLiteral + field.send(dir.downcase) + else + arel_attribute(field).send(dir.downcase) + end } else arg @@ -1160,7 +1186,7 @@ module ActiveRecord end end - STRUCTURAL_OR_METHODS = Relation::VALUE_METHODS - [:extending, :where, :having] + STRUCTURAL_OR_METHODS = Relation::VALUE_METHODS - [:extending, :where, :having, :unscope, :references] def structurally_incompatible_values_for_or(other) STRUCTURAL_OR_METHODS.reject do |method| get_value(method) == other.get_value(method) @@ -1172,23 +1198,15 @@ module ActiveRecord end alias having_clause_factory where_clause_factory - def default_value_for(name) - case name - when :create_with - FROZEN_EMPTY_HASH - when :readonly - false - when :where, :having - Relation::WhereClause.empty - when :from - Relation::FromClause.empty - when *Relation::MULTI_VALUE_METHODS - FROZEN_EMPTY_ARRAY - when *Relation::SINGLE_VALUE_METHODS - nil - else - raise ArgumentError, "unknown relation value #{name.inspect}" - end + DEFAULT_VALUES = { + create_with: FROZEN_EMPTY_HASH, + where: Relation::WhereClause.empty, + having: Relation::WhereClause.empty, + from: Relation::FromClause.empty + } + + Relation::MULTI_VALUE_METHODS.each do |value| + DEFAULT_VALUES[value] ||= FROZEN_EMPTY_ARRAY end end end diff --git a/activerecord/lib/active_record/relation/record_fetch_warning.rb b/activerecord/lib/active_record/relation/record_fetch_warning.rb index 31544c730e..a7d07d23e1 100644 --- a/activerecord/lib/active_record/relation/record_fetch_warning.rb +++ b/activerecord/lib/active_record/relation/record_fetch_warning.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class Relation module RecordFetchWarning diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb index ada89b5ec3..562e04194c 100644 --- a/activerecord/lib/active_record/relation/spawn_methods.rb +++ b/activerecord/lib/active_record/relation/spawn_methods.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/hash/except" require "active_support/core_ext/hash/slice" require "active_record/relation/merger" @@ -67,7 +69,7 @@ module ActiveRecord private def relation_with(values) - result = Relation.create(klass, table, predicate_builder, values) + result = Relation.create(klass, values: values) result.extend(*extending_values) if extending_values.any? result end diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index 417b24c7bb..a502713e56 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -1,65 +1,63 @@ +# frozen_string_literal: true + module ActiveRecord class Relation class WhereClause # :nodoc: - attr_reader :binds - delegate :any?, :empty?, to: :predicates - def initialize(predicates, binds) + def initialize(predicates) @predicates = predicates - @binds = binds end def +(other) WhereClause.new( predicates + other.predicates, - binds + other.binds, + ) + end + + def -(other) + WhereClause.new( + predicates - other.predicates, ) end def merge(other) WhereClause.new( predicates_unreferenced_by(other) + other.predicates, - non_conflicting_binds(other) + other.binds, ) end def except(*columns) - WhereClause.new(*except_predicates_and_binds(columns)) + WhereClause.new(except_predicates(columns)) end def or(other) - if empty? - self - elsif other.empty? - other + left = self - other + common = self - left + right = other - common + + if left.empty? || right.empty? + common else - WhereClause.new( - [ast.or(other.ast)], - binds + other.binds + or_clause = WhereClause.new( + [left.ast.or(right.ast)], ) + common + or_clause end end def to_h(table_name = nil) - equalities = predicates.grep(Arel::Nodes::Equality) + equalities = equalities(predicates) if table_name equalities = equalities.select do |node| node.left.relation.name == table_name end end - binds = self.binds.map { |attr| [attr.name, attr.value] }.to_h - equalities.map { |node| - name = node.left.name - [name, binds.fetch(name.to_s) { - case node.right - when Array then node.right.map(&:val) - when Arel::Nodes::Casted, Arel::Nodes::Quoted - node.right.val - end - }] + name = node.left.name.to_s + value = extract_node_value(node.right) + [name, value] }.to_h end @@ -69,20 +67,17 @@ module ActiveRecord def ==(other) other.is_a?(WhereClause) && - predicates == other.predicates && - binds == other.binds + predicates == other.predicates end def invert - WhereClause.new(inverted_predicates, binds) + WhereClause.new(inverted_predicates) end def self.empty - @empty ||= new([], []) + @empty ||= new([]) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. protected attr_reader :predicates @@ -95,6 +90,20 @@ module ActiveRecord end private + def equalities(predicates) + equalities = [] + + predicates.each do |node| + case node + when Arel::Nodes::Equality + equalities << node + when Arel::Nodes::And + equalities.concat equalities(node.children) + end + end + + equalities + end def predicates_unreferenced_by(other) predicates.reject do |n| @@ -106,12 +115,6 @@ module ActiveRecord node.respond_to?(:operator) && node.operator == :== end - def non_conflicting_binds(other) - conflicts = referenced_columns & other.referenced_columns - conflicts.map! { |node| node.name.to_s } - binds.reject { |attr| conflicts.include?(attr.name) } - end - def inverted_predicates predicates.map { |node| invert_predicate(node) } end @@ -131,43 +134,22 @@ module ActiveRecord end end - def except_predicates_and_binds(columns) - except_binds = [] - binds_index = 0 - - predicates = self.predicates.reject do |node| - except = \ - case node - when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual - binds_contains = node.grep(Arel::Nodes::BindParam).size - subrelation = (node.left.kind_of?(Arel::Attributes::Attribute) ? node.left : node.right) - columns.include?(subrelation.name.to_s) - end - - if except && binds_contains > 0 - (binds_index...(binds_index + binds_contains)).each do |i| - except_binds[i] = true - end - - binds_index += binds_contains + def except_predicates(columns) + predicates.reject do |node| + case node + when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual + subrelation = (node.left.kind_of?(Arel::Attributes::Attribute) ? node.left : node.right) + columns.include?(subrelation.name.to_s) end - - except end - - binds = self.binds.reject.with_index do |_, i| - except_binds[i] - end - - [predicates, binds] end def predicates_with_wrapped_sql_literals non_empty_predicates.map do |node| - if Arel::Nodes::Equality === node - node - else + case node + when Arel::Nodes::SqlLiteral, ::String wrap_sql_literal(node) + else node end end end @@ -183,6 +165,22 @@ module ActiveRecord end Arel::Nodes::Grouping.new(node) end + + def extract_node_value(node) + case node + when Array + node.map { |v| extract_node_value(v) } + when Arel::Nodes::Casted, Arel::Nodes::Quoted + node.val + when Arel::Nodes::BindParam + value = node.value + if value.respond_to?(:value_before_type_cast) + value.value_before_type_cast + else + value + end + end + end end end end diff --git a/activerecord/lib/active_record/relation/where_clause_factory.rb b/activerecord/lib/active_record/relation/where_clause_factory.rb index 04bee73e8f..c1b3eea9df 100644 --- a/activerecord/lib/active_record/relation/where_clause_factory.rb +++ b/activerecord/lib/active_record/relation/where_clause_factory.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord class Relation class WhereClauseFactory # :nodoc: @@ -9,69 +11,23 @@ module ActiveRecord def build(opts, other) case opts when String, Array - parts = [klass.send(:sanitize_sql, other.empty? ? opts : ([opts] + other))] + parts = [klass.sanitize_sql(other.empty? ? opts : ([opts] + other))] when Hash attributes = predicate_builder.resolve_column_aliases(opts) - attributes = klass.send(:expand_hash_conditions_for_aggregates, attributes) attributes.stringify_keys! - if perform_case_sensitive?(options = other.last) - parts, binds = build_for_case_sensitive(attributes, options) - else - attributes, binds = predicate_builder.create_binds(attributes) - parts = predicate_builder.build_from_hash(attributes) - end + parts = predicate_builder.build_from_hash(attributes) when Arel::Nodes::Node parts = [opts] else raise ArgumentError, "Unsupported argument type: #{opts} (#{opts.class})" end - WhereClause.new(parts, binds || []) + WhereClause.new(parts) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :klass, :predicate_builder - private - - def perform_case_sensitive?(options) - options && options.key?(:case_sensitive) - end - - def build_for_case_sensitive(attributes, options) - parts, binds = [], [] - table = klass.arel_table - - attributes.each do |attribute, value| - if reflection = klass._reflect_on_association(attribute) - attribute = reflection.foreign_key.to_s - value = value[reflection.klass.primary_key] unless value.nil? - end - - if value.nil? - parts << table[attribute].eq(value) - else - column = klass.column_for_attribute(attribute) - - binds << predicate_builder.send(:build_bind_param, attribute, value) - value = Arel::Nodes::BindParam.new - - predicate = if options[:case_sensitive] - klass.connection.case_sensitive_comparison(table, attribute, column, value) - else - klass.connection.case_insensitive_comparison(table, attribute, column, value) - end - - parts << predicate - end - end - - [parts, binds] - end + attr_reader :klass, :predicate_builder end end end diff --git a/activerecord/lib/active_record/result.rb b/activerecord/lib/active_record/result.rb index 26b1d48e9e..3b2556b1c8 100644 --- a/activerecord/lib/active_record/result.rb +++ b/activerecord/lib/active_record/result.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord ### # This class encapsulates a result returned from calling @@ -41,6 +43,11 @@ module ActiveRecord @column_types = column_types end + # Returns true if this result set includes the column named +name+ + def includes_column?(name) + @columns.include? name + end + # Returns the number of elements in the rows array. def length @rows.length @@ -95,12 +102,21 @@ module ActiveRecord end def cast_values(type_overrides = {}) # :nodoc: - types = columns.map { |name| column_type(name, type_overrides) } - result = rows.map do |values| - types.zip(values).map { |type, value| type.deserialize(value) } - end + if columns.one? + # Separated to avoid allocating an array per row + + type = column_type(columns.first, type_overrides) + + rows.map do |(value)| + type.deserialize(value) + end + else + types = columns.map { |name| column_type(name, type_overrides) } - columns.one? ? result.map!(&:first) : result + rows.map do |values| + Array.new(values.size) { |i| types[i].deserialize(values[i]) } + end + end end def initialize_copy(other) @@ -123,7 +139,9 @@ module ActiveRecord begin # We freeze the strings to prevent them getting duped when # used as keys in ActiveRecord::Base's @attributes hash - columns = @columns.map { |c| c.dup.freeze } + columns = @columns.map(&:-@) + length = columns.length + @rows.map { |row| # In the past we used Hash[columns.zip(row)] # though elegant, the verbose way is much more efficient @@ -132,8 +150,6 @@ module ActiveRecord hash = {} index = 0 - length = columns.length - while index < length hash[columns[index]] = row[index] index += 1 diff --git a/activerecord/lib/active_record/runtime_registry.rb b/activerecord/lib/active_record/runtime_registry.rb index b79eb2263f..4975cb8967 100644 --- a/activerecord/lib/active_record/runtime_registry.rb +++ b/activerecord/lib/active_record/runtime_registry.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/per_thread_registry" module ActiveRecord diff --git a/activerecord/lib/active_record/sanitization.rb b/activerecord/lib/active_record/sanitization.rb index 64bda1539c..3485d9e557 100644 --- a/activerecord/lib/active_record/sanitization.rb +++ b/activerecord/lib/active_record/sanitization.rb @@ -1,74 +1,139 @@ +# frozen_string_literal: true + module ActiveRecord module Sanitization extend ActiveSupport::Concern module ClassMethods - private + # Accepts an array or string of SQL conditions and sanitizes + # them into a valid SQL fragment for a WHERE clause. + # + # sanitize_sql_for_conditions(["name=? and group_id=?", "foo'bar", 4]) + # # => "name='foo''bar' and group_id=4" + # + # sanitize_sql_for_conditions(["name=:name and group_id=:group_id", name: "foo'bar", group_id: 4]) + # # => "name='foo''bar' and group_id='4'" + # + # sanitize_sql_for_conditions(["name='%s' and group_id='%s'", "foo'bar", 4]) + # # => "name='foo''bar' and group_id='4'" + # + # sanitize_sql_for_conditions("name='foo''bar' and group_id='4'") + # # => "name='foo''bar' and group_id='4'" + def sanitize_sql_for_conditions(condition) + return nil if condition.blank? - # Accepts an array or string of SQL conditions and sanitizes - # them into a valid SQL fragment for a WHERE clause. - # - # sanitize_sql_for_conditions(["name=? and group_id=?", "foo'bar", 4]) - # # => "name='foo''bar' and group_id=4" - # - # sanitize_sql_for_conditions(["name=:name and group_id=:group_id", name: "foo'bar", group_id: 4]) - # # => "name='foo''bar' and group_id='4'" - # - # sanitize_sql_for_conditions(["name='%s' and group_id='%s'", "foo'bar", 4]) - # # => "name='foo''bar' and group_id='4'" - # - # sanitize_sql_for_conditions("name='foo''bar' and group_id='4'") - # # => "name='foo''bar' and group_id='4'" - def sanitize_sql_for_conditions(condition) # :doc: - return nil if condition.blank? - - case condition - when Array; sanitize_sql_array(condition) - else condition - end + case condition + when Array; sanitize_sql_array(condition) + else condition end - alias :sanitize_sql :sanitize_sql_for_conditions - alias :sanitize_conditions :sanitize_sql - deprecate sanitize_conditions: :sanitize_sql + end + alias :sanitize_sql :sanitize_sql_for_conditions - # Accepts an array, hash, or string of SQL conditions and sanitizes - # them into a valid SQL fragment for a SET clause. - # - # sanitize_sql_for_assignment(["name=? and group_id=?", nil, 4]) - # # => "name=NULL and group_id=4" - # - # sanitize_sql_for_assignment(["name=:name and group_id=:group_id", name: nil, group_id: 4]) - # # => "name=NULL and group_id=4" - # - # Post.send(:sanitize_sql_for_assignment, { name: nil, group_id: 4 }) - # # => "`posts`.`name` = NULL, `posts`.`group_id` = 4" - # - # sanitize_sql_for_assignment("name=NULL and group_id='4'") - # # => "name=NULL and group_id='4'" - def sanitize_sql_for_assignment(assignments, default_table_name = table_name) # :doc: - case assignments - when Array; sanitize_sql_array(assignments) - when Hash; sanitize_sql_hash_for_assignment(assignments, default_table_name) - else assignments - end + # Accepts an array, hash, or string of SQL conditions and sanitizes + # them into a valid SQL fragment for a SET clause. + # + # sanitize_sql_for_assignment(["name=? and group_id=?", nil, 4]) + # # => "name=NULL and group_id=4" + # + # sanitize_sql_for_assignment(["name=:name and group_id=:group_id", name: nil, group_id: 4]) + # # => "name=NULL and group_id=4" + # + # Post.sanitize_sql_for_assignment({ name: nil, group_id: 4 }) + # # => "`posts`.`name` = NULL, `posts`.`group_id` = 4" + # + # sanitize_sql_for_assignment("name=NULL and group_id='4'") + # # => "name=NULL and group_id='4'" + def sanitize_sql_for_assignment(assignments, default_table_name = table_name) + case assignments + when Array; sanitize_sql_array(assignments) + when Hash; sanitize_sql_hash_for_assignment(assignments, default_table_name) + else assignments end + end - # Accepts an array, or string of SQL conditions and sanitizes - # them into a valid SQL fragment for an ORDER clause. - # - # sanitize_sql_for_order(["field(id, ?)", [1,3,2]]) - # # => "field(id, 1,3,2)" - # - # sanitize_sql_for_order("id ASC") - # # => "id ASC" - def sanitize_sql_for_order(condition) # :doc: - if condition.is_a?(Array) && condition.first.to_s.include?("?") - sanitize_sql_array(condition) - else - condition + # Accepts an array, or string of SQL conditions and sanitizes + # them into a valid SQL fragment for an ORDER clause. + # + # sanitize_sql_for_order(["field(id, ?)", [1,3,2]]) + # # => "field(id, 1,3,2)" + # + # sanitize_sql_for_order("id ASC") + # # => "id ASC" + def sanitize_sql_for_order(condition) + if condition.is_a?(Array) && condition.first.to_s.include?("?") + disallow_raw_sql!([condition.first], + permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER + ) + + # Ensure we aren't dealing with a subclass of String that might + # override methods we use (eg. Arel::Nodes::SqlLiteral). + if condition.first.kind_of?(String) && !condition.first.instance_of?(String) + condition = [String.new(condition.first), *condition[1..-1]] end + + Arel.sql(sanitize_sql_array(condition)) + else + condition + end + end + + # Sanitizes a hash of attribute/value pairs into SQL conditions for a SET clause. + # + # sanitize_sql_hash_for_assignment({ status: nil, group_id: 1 }, "posts") + # # => "`posts`.`status` = NULL, `posts`.`group_id` = 1" + def sanitize_sql_hash_for_assignment(attrs, table) + c = connection + attrs.map do |attr, value| + type = type_for_attribute(attr) + value = type.serialize(type.cast(value)) + "#{c.quote_table_name_for_assignment(table, attr)} = #{c.quote(value)}" + end.join(", ") + end + + # Sanitizes a +string+ so that it is safe to use within an SQL + # LIKE statement. This method uses +escape_character+ to escape all occurrences of "\", "_" and "%". + # + # sanitize_sql_like("100%") + # # => "100\\%" + # + # sanitize_sql_like("snake_cased_string") + # # => "snake\\_cased\\_string" + # + # sanitize_sql_like("100%", "!") + # # => "100!%" + # + # sanitize_sql_like("snake_cased_string", "!") + # # => "snake!_cased!_string" + def sanitize_sql_like(string, escape_character = "\\") + pattern = Regexp.union(escape_character, "%", "_") + string.gsub(pattern) { |x| [escape_character, x].join } + end + + # Accepts an array of conditions. The array has each value + # sanitized and interpolated into the SQL statement. + # + # sanitize_sql_array(["name=? and group_id=?", "foo'bar", 4]) + # # => "name='foo''bar' and group_id=4" + # + # sanitize_sql_array(["name=:name and group_id=:group_id", name: "foo'bar", group_id: 4]) + # # => "name='foo''bar' and group_id=4" + # + # sanitize_sql_array(["name='%s' and group_id='%s'", "foo'bar", 4]) + # # => "name='foo''bar' and group_id='4'" + def sanitize_sql_array(ary) + statement, *values = ary + if values.first.is_a?(Hash) && /:\w+/.match?(statement) + replace_named_bind_variables(statement, values.first) + elsif statement.include?("?") + replace_bind_variables(statement, values) + elsif statement.blank? + statement + else + statement % values.collect { |value| connection.quote_string(value.to_s) } end + end + private # Accepts a hash of SQL conditions and replaces those attributes # that correspond to a {#composed_of}[rdoc-ref:Aggregations::ClassMethods#composed_of] # relationship with their expanded aggregate attribute values. @@ -90,10 +155,12 @@ module ActiveRecord if aggregation = reflect_on_aggregation(attr.to_sym) mapping = aggregation.mapping mapping.each do |field_attr, aggregate_attr| - if mapping.size == 1 && !value.respond_to?(aggregate_attr) - expanded_attrs[field_attr] = value + expanded_attrs[field_attr] = if value.is_a?(Array) + value.map { |it| it.send(aggregate_attr) } + elsif mapping.size == 1 && !value.respond_to?(aggregate_attr) + value else - expanded_attrs[field_attr] = value.send(aggregate_attr) + value.send(aggregate_attr) end end else @@ -102,61 +169,7 @@ module ActiveRecord end expanded_attrs end - - # Sanitizes a hash of attribute/value pairs into SQL conditions for a SET clause. - # - # sanitize_sql_hash_for_assignment({ status: nil, group_id: 1 }, "posts") - # # => "`posts`.`status` = NULL, `posts`.`group_id` = 1" - def sanitize_sql_hash_for_assignment(attrs, table) # :doc: - c = connection - attrs.map do |attr, value| - value = type_for_attribute(attr.to_s).serialize(value) - "#{c.quote_table_name_for_assignment(table, attr)} = #{c.quote(value)}" - end.join(", ") - end - - # Sanitizes a +string+ so that it is safe to use within an SQL - # LIKE statement. This method uses +escape_character+ to escape all occurrences of "\", "_" and "%". - # - # sanitize_sql_like("100%") - # # => "100\\%" - # - # sanitize_sql_like("snake_cased_string") - # # => "snake\\_cased\\_string" - # - # sanitize_sql_like("100%", "!") - # # => "100!%" - # - # sanitize_sql_like("snake_cased_string", "!") - # # => "snake!_cased!_string" - def sanitize_sql_like(string, escape_character = "\\") # :doc: - pattern = Regexp.union(escape_character, "%", "_") - string.gsub(pattern) { |x| [escape_character, x].join } - end - - # Accepts an array of conditions. The array has each value - # sanitized and interpolated into the SQL statement. - # - # sanitize_sql_array(["name=? and group_id=?", "foo'bar", 4]) - # # => "name='foo''bar' and group_id=4" - # - # sanitize_sql_array(["name=:name and group_id=:group_id", name: "foo'bar", group_id: 4]) - # # => "name='foo''bar' and group_id=4" - # - # sanitize_sql_array(["name='%s' and group_id='%s'", "foo'bar", 4]) - # # => "name='foo''bar' and group_id='4'" - def sanitize_sql_array(ary) # :doc: - statement, *values = ary - if values.first.is_a?(Hash) && /:\w+/.match?(statement) - replace_named_bind_variables(statement, values.first) - elsif statement.include?("?") - replace_bind_variables(statement, values) - elsif statement.blank? - statement - else - statement % values.collect { |value| connection.quote_string(value.to_s) } - end - end + deprecate :expand_hash_conditions_for_aggregates def replace_bind_variables(statement, values) raise_if_bind_arity_mismatch(statement, statement.count("?"), values.size) @@ -205,10 +218,5 @@ module ActiveRecord end end end - - def quoted_id # :nodoc: - self.class.connection.quote(@attributes[self.class.primary_key].value_for_database) - end - deprecate :quoted_id end end diff --git a/activerecord/lib/active_record/schema.rb b/activerecord/lib/active_record/schema.rb index 5104427339..216359867c 100644 --- a/activerecord/lib/active_record/schema.rb +++ b/activerecord/lib/active_record/schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record \Schema # @@ -37,7 +39,7 @@ module ActiveRecord # The +info+ hash is optional, and if given is used to define metadata # about the current schema (currently, only the schema's version): # - # ActiveRecord::Schema.define(version: 20380119000001) do + # ActiveRecord::Schema.define(version: 2038_01_19_000001) do # ... # end def self.define(info = {}, &block) @@ -53,7 +55,7 @@ module ActiveRecord end ActiveRecord::InternalMetadata.create_table - ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment + ActiveRecord::InternalMetadata[:environment] = connection.migration_context.current_environment end private diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index 15533f0151..d475e77444 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "stringio" module ActiveRecord @@ -11,14 +13,19 @@ module ActiveRecord ## # :singleton-method: # A list of tables which should not be dumped to the schema. - # Acceptable values are strings as well as regexp. - # This setting is only used if ActiveRecord::Base.schema_format == :ruby - cattr_accessor :ignore_tables - @@ignore_tables = [] + # Acceptable values are strings as well as regexp if ActiveRecord::Base.schema_format == :ruby. + # Only strings are accepted if ActiveRecord::Base.schema_format == :sql. + cattr_accessor :ignore_tables, default: [] + + ## + # :singleton-method: + # Specify a custom regular expression matching foreign keys which name + # should not be dumped to db/schema.rb. + cattr_accessor :fk_ignore_pattern, default: /^fk_rails_[0-9a-f]{10}$/ class << self def dump(connection = ActiveRecord::Base.connection, stream = STDOUT, config = ActiveRecord::Base) - new(connection, generate_options(config)).dump(stream) + connection.create_schema_dumper(generate_options(config)).dump(stream) stream end @@ -43,23 +50,32 @@ module ActiveRecord def initialize(connection, options = {}) @connection = connection - @version = Migrator::current_version rescue nil + @version = connection.migration_context.current_version rescue nil @options = options end - def header(stream) - define_params = @version ? "version: #{@version}" : "" + # turns 20170404131909 into "2017_04_04_131909" + def formatted_version + stringified = @version.to_s + return stringified unless stringified.length == 14 + stringified.insert(4, "_").insert(7, "_").insert(10, "_") + end + + def define_params + @version ? "version: #{formatted_version}" : "" + end + def header(stream) stream.puts <<HEADER # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. @@ -72,20 +88,12 @@ HEADER stream.puts "end" end + # extensions are only supported by PostgreSQL def extensions(stream) - return unless @connection.supports_extensions? - extensions = @connection.extensions - if extensions.any? - stream.puts " # These are extensions that must be enabled in order to support this database" - extensions.each do |extension| - stream.puts " enable_extension #{extension.inspect}" - end - stream.puts - end end def tables(stream) - sorted_tables = @connection.data_sources.sort - @connection.views + sorted_tables = @connection.tables.sort sorted_tables.each do |table_name| table(table_name, stream) unless ignored?(table_name) @@ -113,7 +121,7 @@ HEADER when String tbl.print ", primary_key: #{pk.inspect}" unless pk == "id" pkcol = columns.detect { |c| c.name == pk } - pkcolspec = @connection.column_spec_for_primary_key(pkcol) + pkcolspec = column_spec_for_primary_key(pkcol) if pkcolspec.present? tbl.print ", #{format_colspec(pkcolspec)}" end @@ -122,20 +130,19 @@ HEADER else tbl.print ", id: false" end - tbl.print ", force: :cascade" table_options = @connection.table_options(table) if table_options.present? tbl.print ", #{format_options(table_options)}" end - tbl.puts " do |t|" + tbl.puts ", force: :cascade do |t|" # then dump all non-primary key columns columns.each do |column| raise StandardError, "Unknown type '#{column.sql_type}' for column '#{column.name}'" unless @connection.valid_type?(column.type) next if column.name == pk - type, colspec = @connection.column_spec(column) + type, colspec = column_spec(column) tbl.print " t.#{type} #{column.name.inspect}" tbl.print ", #{format_colspec(colspec)}" if colspec.present? tbl.puts @@ -153,8 +160,6 @@ HEADER stream.puts "# #{e.message}" stream.puts end - - stream end # Keep it for indexing materialized views @@ -185,8 +190,9 @@ HEADER "name: #{index.name.inspect}", ] index_parts << "unique: true" if index.unique - index_parts << "length: { #{format_options(index.lengths)} }" if index.lengths.present? - index_parts << "order: { #{format_options(index.orders)} }" if index.orders.present? + index_parts << "length: #{format_index_parts(index.lengths)}" if index.lengths.present? + index_parts << "order: #{format_index_parts(index.orders)}" if index.orders.present? + index_parts << "opclass: #{format_index_parts(index.opclasses)}" if index.opclasses.present? index_parts << "where: #{index.where.inspect}" if index.where index_parts << "using: #{index.using.inspect}" if !@connection.default_index_type?(index) index_parts << "type: #{index.type.inspect}" if index.type @@ -210,7 +216,7 @@ HEADER parts << "primary_key: #{foreign_key.primary_key.inspect}" end - if foreign_key.name !~ /^fk_rails_[0-9a-f]{10}$/ + if foreign_key.export_name_on_schema_dump? parts << "name: #{foreign_key.name.inspect}" end @@ -232,8 +238,18 @@ HEADER options.map { |key, value| "#{key}: #{value.inspect}" }.join(", ") end + def format_index_parts(options) + if options.is_a?(Hash) + "{ #{format_options(options)} }" + else + options.inspect + end + end + def remove_prefix_and_suffix(table) - table.gsub(/^(#{@options[:table_name_prefix]})(.+)(#{@options[:table_name_suffix]})$/, "\\2") + prefix = Regexp.escape(@options[:table_name_prefix].to_s) + suffix = Regexp.escape(@options[:table_name_suffix].to_s) + table.sub(/\A#{prefix}(.+)#{suffix}\z/, "\\1") end def ignored?(table_name) diff --git a/activerecord/lib/active_record/schema_migration.rb b/activerecord/lib/active_record/schema_migration.rb index 5efbcff96a..f2d8b038fa 100644 --- a/activerecord/lib/active_record/schema_migration.rb +++ b/activerecord/lib/active_record/schema_migration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/scoping/default" require "active_record/scoping/named" @@ -39,7 +41,11 @@ module ActiveRecord end def normalized_versions - pluck(:version).map { |v| normalize_migration_number v } + all_versions.map { |v| normalize_migration_number v } + end + + def all_versions + order(:version).pluck(:version) end end diff --git a/activerecord/lib/active_record/scoping.rb b/activerecord/lib/active_record/scoping.rb index 7c00e7e4ed..9eba1254a4 100644 --- a/activerecord/lib/active_record/scoping.rb +++ b/activerecord/lib/active_record/scoping.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/per_thread_registry" module ActiveRecord @@ -9,33 +11,33 @@ module ActiveRecord include Named end - module ClassMethods - def current_scope #:nodoc: - ScopeRegistry.value_for(:current_scope, self) - end - - def current_scope=(scope) #:nodoc: - ScopeRegistry.set_value_for(:current_scope, self, scope) - end - + module ClassMethods # :nodoc: # Collects attributes from scopes that should be applied when creating # an AR instance for the particular class this is called on. - def scope_attributes # :nodoc: + def scope_attributes all.scope_for_create end # Are there attributes associated with this scope? - def scope_attributes? # :nodoc: + def scope_attributes? current_scope end + + private + def current_scope(skip_inherited_scope = false) + ScopeRegistry.value_for(:current_scope, self, skip_inherited_scope) + end + + def current_scope=(scope) + ScopeRegistry.set_value_for(:current_scope, self, scope) + end end def populate_with_current_scope_attributes # :nodoc: return unless self.class.scope_attributes? - self.class.scope_attributes.each do |att, value| - send("#{att}=", value) if respond_to?("#{att}=") - end + attributes = self.class.scope_attributes + _assign_attributes(attributes) if attributes.any? end def initialize_internals_callback # :nodoc: @@ -75,8 +77,9 @@ module ActiveRecord end # Obtains the value for a given +scope_type+ and +model+. - def value_for(scope_type, model) + def value_for(scope_type, model, skip_inherited_scope = false) raise_invalid_scope_type!(scope_type) + return @registry[scope_type][model.name] if skip_inherited_scope klass = model base = model.base_class while klass <= base diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 2daa48859a..6caf9b3251 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Scoping module Default @@ -5,11 +7,8 @@ module ActiveRecord included do # Stores the default scope for the class. - class_attribute :default_scopes, instance_writer: false, instance_predicate: false - class_attribute :default_scope_override, instance_writer: false, instance_predicate: false - - self.default_scopes = [] - self.default_scope_override = nil + class_attribute :default_scopes, instance_writer: false, instance_predicate: false, default: [] + class_attribute :default_scope_override, instance_writer: false, instance_predicate: false, default: nil end module ClassMethods @@ -32,7 +31,14 @@ module ActiveRecord # Post.limit(10) # Fires "SELECT * FROM posts LIMIT 10" # } def unscoped - block_given? ? relation.scoping { yield } : relation + block_given? ? _scoping(relation) { yield } : relation + end + + def _scoping(relation) # :nodoc: + previous, self.current_scope = current_scope(true), relation + yield + ensure + self.current_scope = previous end # Are there attributes associated with this scope? @@ -110,13 +116,17 @@ module ActiveRecord if default_scope_override # The user has defined their own default scope method, so call that - evaluate_default_scope { default_scope } + evaluate_default_scope do + if scope = default_scope + (base_rel ||= relation).merge!(scope) + end + end elsif default_scopes.any? base_rel ||= relation evaluate_default_scope do default_scopes.inject(base_rel) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge(base_rel.instance_exec(&scope)) + default_scope.merge!(base_rel.instance_exec(&scope)) end end end diff --git a/activerecord/lib/active_record/scoping/named.rb b/activerecord/lib/active_record/scoping/named.rb index 27cdf8cb7e..573d97b819 100644 --- a/activerecord/lib/active_record/scoping/named.rb +++ b/activerecord/lib/active_record/scoping/named.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/array" require "active_support/core_ext/hash/except" require "active_support/core_ext/kernel/singleton_class" @@ -22,20 +24,36 @@ module ActiveRecord # You can define a scope that applies to all finders using # {default_scope}[rdoc-ref:Scoping::Default::ClassMethods#default_scope]. def all - if current_scope - current_scope.clone + scope = current_scope + + if scope + if self == scope.klass + scope.clone + else + relation.merge!(scope) + end else default_scoped end end - def default_scoped # :nodoc: - scope = build_default_scope + def scope_for_association(scope = relation) # :nodoc: + if current_scope&.empty_scope? + scope + else + default_scoped(scope) + end + end - if scope - relation.spawn.merge!(scope) + def default_scoped(scope = relation) # :nodoc: + build_default_scope(scope) || scope + end + + def default_extensions # :nodoc: + if scope = current_scope || build_default_scope + scope.extensions else - relation + [] end end @@ -151,22 +169,26 @@ module ActiveRecord "a class method with the same name." end + if method_defined_within?(name, Relation) + raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ + "on the model \"#{self.name}\", but ActiveRecord::Relation already defined " \ + "an instance method with the same name." + end + valid_scope_name?(name) extension = Module.new(&block) if block if body.respond_to?(:to_proc) singleton_class.send(:define_method, name) do |*args| - scope = all.scoping { instance_exec(*args, &body) } + scope = all._exec_scope(*args, &body) scope = scope.extending(extension) if extension - - scope || all + scope end else singleton_class.send(:define_method, name) do |*args| - scope = all.scoping { body.call(*args) } + scope = body.call(*args) || all scope = scope.extending(extension) if extension - - scope || all + scope end end end diff --git a/activerecord/lib/active_record/secure_token.rb b/activerecord/lib/active_record/secure_token.rb index 115799cc20..bcdb33901b 100644 --- a/activerecord/lib/active_record/secure_token.rb +++ b/activerecord/lib/active_record/secure_token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module SecureToken extend ActiveSupport::Concern diff --git a/activerecord/lib/active_record/serialization.rb b/activerecord/lib/active_record/serialization.rb index db2bd0b55e..741fea43ce 100644 --- a/activerecord/lib/active_record/serialization.rb +++ b/activerecord/lib/active_record/serialization.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord #:nodoc: # = Active Record \Serialization module Serialization diff --git a/activerecord/lib/active_record/statement_cache.rb b/activerecord/lib/active_record/statement_cache.rb index 1877489e55..b41d3504fd 100644 --- a/activerecord/lib/active_record/statement_cache.rb +++ b/activerecord/lib/active_record/statement_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # Statement cache is used to cache a single statement in order to avoid creating the AST again. # Initializing the cache is done by passing the statement in the create block: @@ -9,7 +11,7 @@ module ActiveRecord # The cached statement is executed by using the # {connection.execute}[rdoc-ref:ConnectionAdapters::DatabaseStatements#execute] method: # - # cache.execute([], Book, Book.connection) + # cache.execute([], Book.connection) # # The relation returned by the block is cached, and for each # {execute}[rdoc-ref:ConnectionAdapters::DatabaseStatements#execute] @@ -24,7 +26,7 @@ module ActiveRecord # # And pass the bind values as the first argument of +execute+ call. # - # cache.execute(["my book"], Book, Book.connection) + # cache.execute(["my book"], Book.connection) class StatementCache # :nodoc: class Substitute; end # :nodoc: @@ -85,27 +87,34 @@ module ActiveRecord end end - attr_reader :bind_map, :query_builder - def self.create(connection, block = Proc.new) - relation = block.call Params.new - bind_map = BindMap.new relation.bound_attributes - query_builder = connection.cacheable_query(self, relation.arel) - new query_builder, bind_map + relation = block.call Params.new + query_builder, binds = connection.cacheable_query(self, relation.arel) + bind_map = BindMap.new(binds) + new(query_builder, bind_map, relation.klass) end - def initialize(query_builder, bind_map) + def initialize(query_builder, bind_map, klass) @query_builder = query_builder - @bind_map = bind_map + @bind_map = bind_map + @klass = klass end - def execute(params, klass, connection, &block) + def execute(params, connection, &block) bind_values = bind_map.bind params sql = query_builder.sql_for bind_values, connection klass.find_by_sql(sql, bind_values, preparable: true, &block) end - alias :call :execute + + def self.unsupported_value?(value) + case value + when NilClass, Array, Range, Hash, Relation, Base then true + end + end + + private + attr_reader :query_builder, :bind_map, :klass end end diff --git a/activerecord/lib/active_record/store.rb b/activerecord/lib/active_record/store.rb index 006afe7495..3537e2d008 100644 --- a/activerecord/lib/active_record/store.rb +++ b/activerecord/lib/active_record/store.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/hash/indifferent_access" module ActiveRecord @@ -15,8 +17,8 @@ module ActiveRecord # You can set custom coder to encode/decode your serialized attributes to/from different formats. # JSON, YAML, Marshal are supported out of the box. Generally it can be any wrapper that provides +load+ and +dump+. # - # NOTE: If you are using PostgreSQL specific columns like +hstore+ or +json+ there is no need for - # the serialization provided by {.store}[rdoc-ref:rdoc-ref:ClassMethods#store]. + # NOTE: If you are using structured database data types (eg. PostgreSQL +hstore+/+json+, or MySQL 5.7+ + # +json+) there is no need for the serialization provided by {.store}[rdoc-ref:rdoc-ref:ClassMethods#store]. # Simply use {.store_accessor}[rdoc-ref:ClassMethods#store_accessor] instead to generate # the accessor methods. Be aware that these columns use a string keyed hash and do not allow access # using a symbol. @@ -29,10 +31,18 @@ module ActiveRecord # # class User < ActiveRecord::Base # store :settings, accessors: [ :color, :homepage ], coder: JSON + # store :parent, accessors: [ :name ], coder: JSON, prefix: true + # store :spouse, accessors: [ :name ], coder: JSON, prefix: :partner + # store :settings, accessors: [ :two_factor_auth ], suffix: true + # store :settings, accessors: [ :login_retry ], suffix: :config # end # - # u = User.new(color: 'black', homepage: '37signals.com') + # u = User.new(color: 'black', homepage: '37signals.com', parent_name: 'Mary', partner_name: 'Lily') # u.color # Accessor stored attribute + # u.parent_name # Accessor stored attribute with prefix + # u.partner_name # Accessor stored attribute with custom prefix + # u.two_factor_auth_settings # Accessor stored attribute with suffix + # u.login_retry_config # Accessor stored attribute with custom suffix # u.settings[:country] = 'Denmark' # Any attribute, even if not specified with an accessor # # # There is no difference between strings and symbols for accessing custom attributes @@ -42,11 +52,13 @@ module ActiveRecord # # Add additional accessors to an existing store through store_accessor # class SuperUser < User # store_accessor :settings, :privileges, :servants + # store_accessor :parent, :birthday, prefix: true + # store_accessor :settings, :secret_question, suffix: :config # end # # The stored attribute names can be retrieved using {.stored_attributes}[rdoc-ref:rdoc-ref:ClassMethods#stored_attributes]. # - # User.stored_attributes[:settings] # [:color, :homepage] + # User.stored_attributes[:settings] # [:color, :homepage, :two_factor_auth, :login_retry] # # == Overwriting default accessors # @@ -79,19 +91,40 @@ module ActiveRecord module ClassMethods def store(store_attribute, options = {}) serialize store_attribute, IndifferentCoder.new(store_attribute, options[:coder]) - store_accessor(store_attribute, options[:accessors]) if options.has_key? :accessors + store_accessor(store_attribute, options[:accessors], options.slice(:prefix, :suffix)) if options.has_key? :accessors end - def store_accessor(store_attribute, *keys) + def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil) keys = keys.flatten + accessor_prefix = + case prefix + when String, Symbol + "#{prefix}_" + when TrueClass + "#{store_attribute}_" + else + "" + end + accessor_suffix = + case suffix + when String, Symbol + "_#{suffix}" + when TrueClass + "_#{store_attribute}" + else + "" + end + _store_accessors_module.module_eval do keys.each do |key| - define_method("#{key}=") do |value| + accessor_key = "#{accessor_prefix}#{key}#{accessor_suffix}" + + define_method("#{accessor_key}=") do |value| write_store_attribute(store_attribute, key, value) end - define_method(key) do + define_method(accessor_key) do read_store_attribute(store_attribute, key) end end @@ -133,7 +166,7 @@ module ActiveRecord end def store_accessor_for(store_attribute) - type_for_attribute(store_attribute.to_s).accessor + type_for_attribute(store_attribute).accessor end class HashAccessor # :nodoc: diff --git a/activerecord/lib/active_record/suppressor.rb b/activerecord/lib/active_record/suppressor.rb index d9acb1a1dc..8cdb8e0765 100644 --- a/activerecord/lib/active_record/suppressor.rb +++ b/activerecord/lib/active_record/suppressor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # ActiveRecord::Suppressor prevents the receiver from being saved during # a given block. diff --git a/activerecord/lib/active_record/table_metadata.rb b/activerecord/lib/active_record/table_metadata.rb index 71efc1829a..b67479fb6a 100644 --- a/activerecord/lib/active_record/table_metadata.rb +++ b/activerecord/lib/active_record/table_metadata.rb @@ -1,7 +1,8 @@ +# frozen_string_literal: true + module ActiveRecord class TableMetadata # :nodoc: - delegate :foreign_type, :foreign_key, to: :association, prefix: true - delegate :association_primary_key, to: :association + delegate :foreign_type, :foreign_key, :join_primary_key, :join_foreign_key, to: :association, prefix: true def initialize(klass, arel_table, association = nil) @klass = klass @@ -29,7 +30,7 @@ module ActiveRecord def type(column_name) if klass - klass.type_for_attribute(column_name.to_s) + klass.type_for_attribute(column_name) else Type.default_value end @@ -64,10 +65,15 @@ module ActiveRecord association && association.polymorphic? end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected + def aggregated_with?(aggregation_name) + klass && reflect_on_aggregation(aggregation_name) + end + + def reflect_on_aggregation(aggregation_name) + klass.reflect_on_aggregation(aggregation_name) + end + private attr_reader :klass, :arel_table, :association end end diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 82604a915f..16e66982e5 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require "active_record/database_configurations" + module ActiveRecord module Tasks # :nodoc: class DatabaseAlreadyExists < StandardError; end # :nodoc: @@ -6,7 +10,7 @@ module ActiveRecord # ActiveRecord::Tasks::DatabaseTasks is a utility class, which encapsulates # logic behind common tasks used to manage database and migrations. # - # The tasks defined here are used with Rake tasks provided by Active Record. + # The tasks defined here are used with Rails commands provided by Active Record. # # In order to use DatabaseTasks, a few config values need to be set. All the needed # config values are set by Rails already, so it's necessary to do it only if you @@ -52,10 +56,10 @@ module ActiveRecord def check_protected_environments! unless ENV["DISABLE_DATABASE_ENVIRONMENT_CHECK"] - current = ActiveRecord::Migrator.current_environment - stored = ActiveRecord::Migrator.last_stored_environment + current = ActiveRecord::Base.connection.migration_context.current_environment + stored = ActiveRecord::Base.connection.migration_context.last_stored_environment - if ActiveRecord::Migrator.protected_environment? + if ActiveRecord::Base.connection.migration_context.protected_environment? raise ActiveRecord::ProtectedEnvironmentError.new(stored) end @@ -71,9 +75,9 @@ module ActiveRecord @tasks[pattern] = task end - register_task(/mysql/, ActiveRecord::Tasks::MySQLDatabaseTasks) - register_task(/postgresql/, ActiveRecord::Tasks::PostgreSQLDatabaseTasks) - register_task(/sqlite/, ActiveRecord::Tasks::SQLiteDatabaseTasks) + register_task(/mysql/, "ActiveRecord::Tasks::MySQLDatabaseTasks") + register_task(/postgresql/, "ActiveRecord::Tasks::PostgreSQLDatabaseTasks") + register_task(/sqlite/, "ActiveRecord::Tasks::SQLiteDatabaseTasks") def db_dir @db_dir ||= Rails.application.config.paths["db"].first @@ -99,28 +103,33 @@ module ActiveRecord @env ||= Rails.env end + def spec + @spec ||= "primary" + end + def seed_loader @seed_loader ||= Rails.application end def current_config(options = {}) options.reverse_merge! env: env + options[:spec] ||= "primary" if options.has_key?(:config) @current_config = options[:config] else - @current_config ||= ActiveRecord::Base.configurations[options[:env]] + @current_config ||= ActiveRecord::Base.configurations.configs_for(env_name: options[:env], spec_name: options[:spec]).config end end def create(*arguments) configuration = arguments.first class_for_adapter(configuration["adapter"]).new(*arguments).create - $stdout.puts "Created database '#{configuration['database']}'" + $stdout.puts "Created database '#{configuration['database']}'" if verbose? rescue DatabaseAlreadyExists - $stderr.puts "Database '#{configuration['database']}' already exists" + $stderr.puts "Database '#{configuration['database']}' already exists" if verbose? rescue Exception => error $stderr.puts error - $stderr.puts "Couldn't create database for #{configuration.inspect}" + $stderr.puts "Couldn't create '#{configuration['database']}' database. Please check your configuration." raise end @@ -132,6 +141,18 @@ module ActiveRecord end end + def for_each + databases = Rails.application.config.database_configuration + database_configs = ActiveRecord::DatabaseConfigurations.new(databases).configs_for(env_name: Rails.env) + + # if this is a single database application we don't want tasks for each primary database + return if database_configs.count == 1 + + database_configs.each do |db_config| + yield db_config.spec_name + end + end + def create_current(environment = env) each_current_configuration(environment) { |configuration| create configuration @@ -142,7 +163,7 @@ module ActiveRecord def drop(*arguments) configuration = arguments.first class_for_adapter(configuration["adapter"]).new(*arguments).drop - $stdout.puts "Dropped database '#{configuration['database']}'" + $stdout.puts "Dropped database '#{configuration['database']}'" if verbose? rescue ActiveRecord::NoDatabaseError $stderr.puts "Database '#{configuration['database']}' does not exist" rescue Exception => error @@ -162,20 +183,32 @@ module ActiveRecord end def migrate - verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true - version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil - scope = ENV["SCOPE"] - verbose_was, Migration.verbose = Migration.verbose, verbose - Migrator.migrate(migrations_paths, version) do |migration| + check_target_version + + scope = ENV["SCOPE"] + verbose_was, Migration.verbose = Migration.verbose, verbose? + + Base.connection.migration_context.migrate(target_version) do |migration| scope.blank? || scope == migration.scope end + ActiveRecord::Base.clear_cache! ensure Migration.verbose = verbose_was end - def charset_current(environment = env) - charset ActiveRecord::Base.configurations[environment] + def check_target_version + if target_version && !(Migration::MigrationFilenameRegexp.match?(ENV["VERSION"]) || /\A\d+\z/.match?(ENV["VERSION"])) + raise "Invalid format of target version: `VERSION=#{ENV['VERSION']}`" + end + end + + def target_version + ENV["VERSION"].to_i if ENV["VERSION"] && !ENV["VERSION"].empty? + end + + def charset_current(environment = env, specification_name = spec) + charset ActiveRecord::Base.configurations.configs_for(env_name: environment, spec_name: specification_name).config end def charset(*arguments) @@ -183,8 +216,8 @@ module ActiveRecord class_for_adapter(configuration["adapter"]).new(*arguments).charset end - def collation_current(environment = env) - collation ActiveRecord::Base.configurations[environment] + def collation_current(environment = env, specification_name = spec) + collation ActiveRecord::Base.configurations.configs_for(env_name: environment, spec_name: specification_name).config end def collation(*arguments) @@ -221,44 +254,61 @@ module ActiveRecord class_for_adapter(configuration["adapter"]).new(*arguments).structure_load(filename, structure_load_flags) end - def load_schema(configuration, format = ActiveRecord::Base.schema_format, file = nil) # :nodoc: - file ||= schema_file(format) + def load_schema(configuration, format = ActiveRecord::Base.schema_format, file = nil, environment = env, spec_name = "primary") # :nodoc: + file ||= dump_filename(spec_name, format) + + verbose_was, Migration.verbose = Migration.verbose, verbose? && ENV["VERBOSE"] + check_schema_file(file) + ActiveRecord::Base.establish_connection(configuration) case format when :ruby - check_schema_file(file) - ActiveRecord::Base.establish_connection(configuration) load(file) when :sql - check_schema_file(file) structure_load(configuration, file) else raise ArgumentError, "unknown format #{format.inspect}" end ActiveRecord::InternalMetadata.create_table - ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment + ActiveRecord::InternalMetadata[:environment] = environment + ensure + Migration.verbose = verbose_was end def schema_file(format = ActiveRecord::Base.schema_format) + File.join(db_dir, schema_file_type(format)) + end + + def schema_file_type(format = ActiveRecord::Base.schema_format) case format when :ruby - File.join(db_dir, "schema.rb") + "schema.rb" when :sql - File.join(db_dir, "structure.sql") + "structure.sql" end end + def dump_filename(namespace, format = ActiveRecord::Base.schema_format) + filename = if namespace == "primary" + schema_file_type(format) + else + "#{namespace}_#{schema_file_type(format)}" + end + + ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, filename) + end + def load_schema_current(format = ActiveRecord::Base.schema_format, file = nil, environment = env) - each_current_configuration(environment) { |configuration| - load_schema configuration, format, file + each_current_configuration(environment) { |configuration, spec_name, env| + load_schema(configuration, format, file, env, spec_name) } ActiveRecord::Base.establish_connection(environment.to_sym) end def check_schema_file(filename) unless File.exist?(filename) - message = %{#{filename} doesn't exist yet. Run `rails db:migrate` to create it, then try again.} - message << %{ If you do not intend to use a database, you should instead alter #{Rails.root}/config/application.rb to limit the frameworks that will be loaded.} if defined?(::Rails) + message = %{#{filename} doesn't exist yet. Run `rails db:migrate` to create it, then try again.}.dup + message << %{ If you do not intend to use a database, you should instead alter #{Rails.root}/config/application.rb to limit the frameworks that will be loaded.} if defined?(::Rails.root) Kernel.abort message end end @@ -284,27 +334,32 @@ module ActiveRecord end private + def verbose? + ENV["VERBOSE"] ? ENV["VERBOSE"] != "false" : true + end def class_for_adapter(adapter) - key = @tasks.keys.detect { |pattern| adapter[pattern] } - unless key + _key, task = @tasks.each_pair.detect { |pattern, _task| adapter[pattern] } + unless task raise DatabaseNotSupported, "Rake tasks not supported by '#{adapter}' adapter" end - @tasks[key] + task.is_a?(String) ? task.constantize : task end def each_current_configuration(environment) environments = [environment] environments << "test" if environment == "development" - configurations = ActiveRecord::Base.configurations.values_at(*environments) - configurations.compact.each do |configuration| - yield configuration unless configuration["database"].blank? + environments.each do |env| + ActiveRecord::Base.configurations.configs_for(env_name: env).each do |db_config| + yield db_config.config, db_config.spec_name, env + end end end def each_local_configuration - ActiveRecord::Base.configurations.each_value do |configuration| + ActiveRecord::Base.configurations.configs_for.each do |db_config| + configuration = db_config.config next unless configuration["database"] if local_database?(configuration) diff --git a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb index 920830b9cf..eddc6fa223 100644 --- a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb @@ -1,8 +1,8 @@ +# frozen_string_literal: true + module ActiveRecord module Tasks # :nodoc: class MySQLDatabaseTasks # :nodoc: - ACCESS_DENIED_ERROR = 1045 - delegate :connection, :establish_connection, to: ActiveRecord::Base def initialize(configuration) @@ -19,20 +19,6 @@ module ActiveRecord else raise end - rescue error_class => error - if error.respond_to?(:errno) && error.errno == ACCESS_DENIED_ERROR - $stdout.print error.message - establish_connection root_configuration_without_database - connection.create_database configuration["database"], creation_options - if configuration["username"] != "root" - connection.execute grant_statement.gsub(/\s+/, " ").strip - end - establish_connection configuration - else - $stderr.puts error.inspect - $stderr.puts "Couldn't create database for #{configuration.inspect}, #{creation_options.inspect}" - $stderr.puts "(If you set the charset manually, make sure you have a matching collation)" if configuration["encoding"] - end end def drop @@ -59,8 +45,14 @@ module ActiveRecord args.concat(["--no-data"]) args.concat(["--routines"]) args.concat(["--skip-comments"]) - args.concat(Array(extra_flags)) if extra_flags + + ignore_tables = ActiveRecord::SchemaDumper.ignore_tables + if ignore_tables.any? + args += ignore_tables.map { |table| "--ignore-table=#{configuration['database']}.#{table}" } + end + args.concat(["#{configuration['database']}"]) + args.unshift(*extra_flags) if extra_flags run_cmd("mysqldump", args, "dumping") end @@ -69,16 +61,14 @@ module ActiveRecord args = prepare_command_options args.concat(["--execute", %{SET FOREIGN_KEY_CHECKS = 0; SOURCE #{filename}; SET FOREIGN_KEY_CHECKS = 1}]) args.concat(["--database", "#{configuration['database']}"]) - args.concat(Array(extra_flags)) if extra_flags + args.unshift(*extra_flags) if extra_flags run_cmd("mysql", args, "loading") end private - def configuration - @configuration - end + attr_reader :configuration def configuration_without_database configuration.merge("database" => nil) @@ -91,37 +81,6 @@ module ActiveRecord end end - def error_class - if configuration["adapter"].include?("jdbc") - require "active_record/railties/jdbcmysql_error" - ArJdbcMySQL::Error - elsif defined?(Mysql2) - Mysql2::Error - else - StandardError - end - end - - def grant_statement - <<-SQL -GRANT ALL PRIVILEGES ON #{configuration['database']}.* - TO '#{configuration['username']}'@'localhost' -IDENTIFIED BY '#{configuration['password']}' WITH GRANT OPTION; - SQL - end - - def root_configuration_without_database - configuration_without_database.merge( - "username" => "root", - "password" => root_password - ) - end - - def root_password - $stdout.print "Please provide the root password for your MySQL installation\n>" - $stdin.gets.strip - end - def prepare_command_options args = { "host" => "--host", @@ -145,7 +104,7 @@ IDENTIFIED BY '#{configuration['password']}' WITH GRANT OPTION; end def run_cmd_error(cmd, args, action) - msg = "failed to execute: `#{cmd}`\n" + msg = "failed to execute: `#{cmd}`\n".dup msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" msg end diff --git a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb index 5155ced0e2..533e6953a4 100644 --- a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb @@ -1,8 +1,13 @@ +# frozen_string_literal: true + +require "tempfile" + module ActiveRecord module Tasks # :nodoc: class PostgreSQLDatabaseTasks # :nodoc: DEFAULT_ENCODING = ENV["CHARSET"] || "utf8" ON_ERROR_STOP_1 = "ON_ERROR_STOP=1".freeze + SQL_COMMENT_BEGIN = "--".freeze delegate :connection, :establish_connection, :clear_active_connections!, to: ActiveRecord::Base @@ -17,7 +22,7 @@ module ActiveRecord configuration.merge("encoding" => encoding) establish_connection configuration rescue ActiveRecord::StatementInvalid => error - if /database .* already exists/.match?(error.message) + if error.cause.is_a?(PG::DuplicateDatabase) raise DatabaseAlreadyExists else raise @@ -63,8 +68,15 @@ module ActiveRecord "--schema=#{part.strip}" end end + + ignore_tables = ActiveRecord::SchemaDumper.ignore_tables + if ignore_tables.any? + args += ignore_tables.flat_map { |table| ["-T", table] } + end + args << configuration["database"] run_cmd("pg_dump", args, "dumping") + remove_sql_header_comments(filename) File.open(filename, "a") { |f| f << "SET search_path TO #{connection.schema_search_path};\n\n" } end @@ -78,9 +90,7 @@ module ActiveRecord private - def configuration - @configuration - end + attr_reader :configuration def encoding configuration["encoding"] || DEFAULT_ENCODING @@ -105,11 +115,27 @@ module ActiveRecord end def run_cmd_error(cmd, args, action) - msg = "failed to execute:\n" + msg = "failed to execute:\n".dup msg << "#{cmd} #{args.join(' ')}\n\n" msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" msg end + + def remove_sql_header_comments(filename) + removing_comments = true + tempfile = Tempfile.open("uncommented_structure.sql") + begin + File.foreach(filename) do |line| + unless removing_comments && (line.start_with?(SQL_COMMENT_BEGIN) || line.blank?) + tempfile << line + removing_comments = false + end + end + ensure + tempfile.close + end + FileUtils.cp(tempfile.path, filename) + end end end end diff --git a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb index 1f756c2979..d0bad05176 100644 --- a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Tasks # :nodoc: class SQLiteDatabaseTasks # :nodoc: @@ -36,9 +38,18 @@ module ActiveRecord end def structure_dump(filename, extra_flags) - dbfile = configuration["database"] - flags = extra_flags.join(" ") if extra_flags - `sqlite3 #{flags} #{dbfile} .schema > #{filename}` + args = [] + args.concat(Array(extra_flags)) if extra_flags + args << configuration["database"] + + ignore_tables = ActiveRecord::SchemaDumper.ignore_tables + if ignore_tables.any? + condition = ignore_tables.map { |table| connection.quote(table) }.join(", ") + args << "SELECT sql FROM sqlite_master WHERE tbl_name NOT IN (#{condition}) ORDER BY tbl_name, type DESC, name" + else + args << ".schema" + end + run_cmd("sqlite3", args, filename) end def structure_load(filename, extra_flags) @@ -49,12 +60,17 @@ module ActiveRecord private - def configuration - @configuration + attr_reader :configuration, :root + + def run_cmd(cmd, args, out) + fail run_cmd_error(cmd, args) unless Kernel.system(cmd, *args, out: out) end - def root - @root + def run_cmd_error(cmd, args) + msg = "failed to execute:\n".dup + msg << "#{cmd} #{args.join(' ')}\n\n" + msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" + msg end end end diff --git a/activerecord/lib/active_record/test_databases.rb b/activerecord/lib/active_record/test_databases.rb new file mode 100644 index 0000000000..999830ba79 --- /dev/null +++ b/activerecord/lib/active_record/test_databases.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "active_support/testing/parallelization" + +module ActiveRecord + module TestDatabases # :nodoc: + ActiveSupport::Testing::Parallelization.after_fork_hook do |i| + create_and_load_schema(i, env_name: Rails.env) + end + + ActiveSupport::Testing::Parallelization.run_cleanup_hook do + drop(env_name: Rails.env) + end + + def self.create_and_load_schema(i, env_name:) + old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" + + ActiveRecord::Base.configurations.configs_for(env_name: env_name).each do |db_config| + db_config.config["database"] += "-#{i}" + ActiveRecord::Tasks::DatabaseTasks.create(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config.config, ActiveRecord::Base.schema_format, nil, env_name, db_config.spec_name) + end + ensure + ActiveRecord::Base.establish_connection(Rails.env.to_sym) + ENV["VERBOSE"] = old + end + + def self.drop(env_name:) + old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" + + ActiveRecord::Base.configurations.configs_for(env_name: env_name).each do |db_config| + ActiveRecord::Tasks::DatabaseTasks.drop(db_config.config) + end + ensure + ENV["VERBOSE"] = old + end + end +end diff --git a/activerecord/lib/active_record/timestamp.rb b/activerecord/lib/active_record/timestamp.rb index 09d8d1cdd4..d32f971ad1 100644 --- a/activerecord/lib/active_record/timestamp.rb +++ b/activerecord/lib/active_record/timestamp.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module ActiveRecord # = Active Record \Timestamp # @@ -43,8 +44,7 @@ module ActiveRecord extend ActiveSupport::Concern included do - class_attribute :record_timestamps - self.record_timestamps = true + class_attribute :record_timestamps, default: true end def initialize_dup(other) # :nodoc: @@ -52,7 +52,13 @@ module ActiveRecord clear_timestamp_attributes end - class_methods do + module ClassMethods # :nodoc: + def touch_attributes_with_time(*names, time: nil) + attribute_names = timestamp_attributes_for_update_in_model + attribute_names |= names.map(&:to_s) + attribute_names.index_with(time ||= current_time_from_proper_timezone) + end + private def timestamp_attributes_for_create_in_model timestamp_attributes_for_create.select { |c| column_names.include?(c) } @@ -87,7 +93,7 @@ module ActiveRecord all_timestamp_attributes_in_model.each do |column| if !attribute_present?(column) - write_attribute(column, current_time) + _write_attribute(column, current_time) end end end @@ -101,7 +107,7 @@ module ActiveRecord timestamp_attributes_for_update_in_model.each do |column| next if will_save_change_to_attribute?(column) - write_attribute(column, current_time) + _write_attribute(column, current_time) end end super(*args) @@ -127,7 +133,7 @@ module ActiveRecord self.class.send(:current_time_from_proper_timezone) end - def max_updated_column_timestamp(timestamp_names = self.class.send(:timestamp_attributes_for_update)) + def max_updated_column_timestamp(timestamp_names = timestamp_attributes_for_update_in_model) timestamp_names .map { |attr| self[attr] } .compact diff --git a/activerecord/lib/active_record/touch_later.rb b/activerecord/lib/active_record/touch_later.rb index cacde9c881..f70b7c50a2 100644 --- a/activerecord/lib/active_record/touch_later.rb +++ b/activerecord/lib/active_record/touch_later.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record Touch Later module TouchLater diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index 08417aaa0f..c5d5fca672 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # See ActiveRecord::Transactions::ClassMethods for documentation. module Transactions @@ -123,7 +125,7 @@ module ActiveRecord # # statement will cause a PostgreSQL error, even though the unique # # constraint is no longer violated: # Number.create(i: 1) - # # => "PGError: ERROR: current transaction is aborted, commands + # # => "PG::Error: ERROR: current transaction is aborted, commands # # ignored until end of transaction block" # end # @@ -168,7 +170,7 @@ module ActiveRecord # writing, the only database that we're aware of that supports true nested # transactions, is MS-SQL. Because of this, Active Record emulates nested # transactions by using savepoints on MySQL and PostgreSQL. See - # http://dev.mysql.com/doc/refman/5.7/en/savepoint.html + # https://dev.mysql.com/doc/refman/5.7/en/savepoint.html # for more information about savepoints. # # === \Callbacks @@ -188,7 +190,7 @@ module ActiveRecord # # === Caveats # - # If you're on MySQL, then do not use Data Definition Language(DDL) operations in nested + # If you're on MySQL, then do not use Data Definition Language (DDL) operations in nested # transactions blocks that are emulated with savepoints. That is, do not execute statements # like 'CREATE TABLE' inside such blocks. This is because MySQL automatically # releases all savepoints upon executing a DDL operation. When +transaction+ @@ -283,7 +285,7 @@ module ActiveRecord fire_on = Array(options[:on]) assert_valid_transaction_action(fire_on) options[:if] = Array(options[:if]) - options[:if] << "transaction_include_any_action?(#{fire_on})" + options[:if].unshift(-> { transaction_include_any_action?(fire_on) }) end end @@ -304,9 +306,7 @@ module ActiveRecord end def save(*) #:nodoc: - rollback_active_record_state! do - with_transaction_returning_status { super } - end + with_transaction_returning_status { super } end def save!(*) #:nodoc: @@ -317,17 +317,6 @@ module ActiveRecord with_transaction_returning_status { super } end - # Reset id and @new_record if the transaction rolls back. - def rollback_active_record_state! - remember_transaction_record_state - yield - rescue Exception - restore_transaction_record_state - raise - ensure - clear_transaction_record_state - end - def before_committed! # :nodoc: _run_before_commit_without_transaction_enrollment_callbacks _run_before_commit_callbacks @@ -338,11 +327,13 @@ module ActiveRecord # Ensure that it is not called if the object was never persisted (failed create), # but call it after the commit of a destroyed object. def committed!(should_run_callbacks: true) #:nodoc: - if should_run_callbacks && destroyed? || persisted? + if should_run_callbacks && (destroyed? || persisted?) + @_committed_already_called = true _run_commit_without_transaction_enrollment_callbacks _run_commit_callbacks end ensure + @_committed_already_called = false force_clear_transaction_record_state end @@ -380,13 +371,7 @@ module ActiveRecord status = nil self.class.transaction do add_to_transaction - begin - status = yield - rescue ActiveRecord::Rollback - clear_transaction_record_state - status = nil - end - + status = yield raise ActiveRecord::Rollback unless status end status @@ -397,16 +382,26 @@ module ActiveRecord end private + attr_reader :_committed_already_called, :_trigger_update_callback, :_trigger_destroy_callback # Save the new record state and id of a record so it can be restored later if a transaction fails. def remember_transaction_record_state - @_start_transaction_state[:id] = id @_start_transaction_state.reverse_merge!( + id: id, new_record: @new_record, destroyed: @destroyed, frozen?: frozen?, ) @_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) + 1 + remember_new_record_before_last_commit + end + + def remember_new_record_before_last_commit + if _committed_already_called + @_new_record_before_last_commit = false + else + @_new_record_before_last_commit = @_start_transaction_state[:new_record] + end end # Clear the new record state and id of a record. @@ -430,30 +425,24 @@ module ActiveRecord @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]) + if pk && _read_attribute(pk) != restore_state[:id] + _write_attribute(pk, restore_state[:id]) end freeze if restore_state[:frozen?] end end end - # Determine if a record was created or destroyed in a transaction. State should be one of :new_record or :destroyed. - def transaction_record_state(state) - @_start_transaction_state[state] - end - # Determine if a transaction included an action for :create, :update, or :destroy. Used in filtering callbacks. def transaction_include_any_action?(actions) actions.any? do |action| case action when :create - transaction_record_state(:new_record) - when :destroy - defined?(@_trigger_destroy_callback) && @_trigger_destroy_callback + persisted? && @_new_record_before_last_commit when :update - !(transaction_record_state(:new_record) || destroyed?) && - (defined?(@_trigger_update_callback) && @_trigger_update_callback) + !(@_new_record_before_last_commit || destroyed?) && _trigger_update_callback + when :destroy + _trigger_destroy_callback end end end @@ -470,7 +459,7 @@ module ActiveRecord # if it's associated with a transaction, then the state of the Active Record # object will be updated to reflect the current state of the transaction. # - # The +@transaction_state+ variable stores the states of the associated + # The <tt>@transaction_state</tt> variable stores the states of the associated # transaction. This relies on the fact that a transaction can only be in # one rollback or commit (otherwise a list of states would be required). # Each Active Record object inside of a transaction carries that transaction's @@ -489,8 +478,9 @@ module ActiveRecord def update_attributes_from_transaction_state(transaction_state) if transaction_state && transaction_state.finalized? - restore_transaction_record_state if transaction_state.rolledback? - clear_transaction_record_state + 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? end end end diff --git a/activerecord/lib/active_record/translation.rb b/activerecord/lib/active_record/translation.rb index ddcb5f2a7a..82661a328a 100644 --- a/activerecord/lib/active_record/translation.rb +++ b/activerecord/lib/active_record/translation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Translation include ActiveModel::Translation @@ -8,7 +10,7 @@ module ActiveRecord classes = [klass] return classes if klass == ActiveRecord::Base - while klass != klass.base_class + while !klass.base_class? classes << klass = klass.superclass end classes diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index 4f632660a8..c303186ef2 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + require "active_model/type" -require "active_record/type/internal/abstract_json" require "active_record/type/internal/timezone" require "active_record/type/date" require "active_record/type/date_time" require "active_record/type/decimal_without_scale" +require "active_record/type/json" require "active_record/type/time" require "active_record/type/text" require "active_record/type/unsigned_integer" @@ -69,6 +71,7 @@ module ActiveRecord register(:decimal, Type::Decimal, override: false) register(:float, Type::Float, override: false) register(:integer, Type::Integer, override: false) + register(:json, Type::Json, override: false) register(:string, Type::String, override: false) register(:text, Type::Text, override: false) register(:time, Type::Time, override: false) diff --git a/activerecord/lib/active_record/type/adapter_specific_registry.rb b/activerecord/lib/active_record/type/adapter_specific_registry.rb index 7cc866f7a7..b300fdfa05 100644 --- a/activerecord/lib/active_record/type/adapter_specific_registry.rb +++ b/activerecord/lib/active_record/type/adapter_specific_registry.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_model/type/registry" module ActiveRecord @@ -50,8 +52,6 @@ module ActiveRecord priority <=> other.priority end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. protected attr_reader :name, :block, :adapter, :override @@ -112,13 +112,8 @@ module ActiveRecord super | 4 end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :options, :klass - private + attr_reader :options, :klass def matches_options?(**kwargs) options.all? do |key, value| diff --git a/activerecord/lib/active_record/type/date.rb b/activerecord/lib/active_record/type/date.rb index ccafed054e..8177074a20 100644 --- a/activerecord/lib/active_record/type/date.rb +++ b/activerecord/lib/active_record/type/date.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type class Date < ActiveModel::Type::Date diff --git a/activerecord/lib/active_record/type/date_time.rb b/activerecord/lib/active_record/type/date_time.rb index 1fb9380ecd..4acde6b9f8 100644 --- a/activerecord/lib/active_record/type/date_time.rb +++ b/activerecord/lib/active_record/type/date_time.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type class DateTime < ActiveModel::Type::DateTime diff --git a/activerecord/lib/active_record/type/decimal_without_scale.rb b/activerecord/lib/active_record/type/decimal_without_scale.rb index 7ce33e9cd3..a207940dc7 100644 --- a/activerecord/lib/active_record/type/decimal_without_scale.rb +++ b/activerecord/lib/active_record/type/decimal_without_scale.rb @@ -1,9 +1,15 @@ +# frozen_string_literal: true + module ActiveRecord module Type class DecimalWithoutScale < ActiveModel::Type::BigInteger # :nodoc: def type :decimal end + + def type_cast_for_schema(value) + value.to_s.inspect + end end end end diff --git a/activerecord/lib/active_record/type/hash_lookup_type_map.rb b/activerecord/lib/active_record/type/hash_lookup_type_map.rb index 0145d5d6c1..db9853fbcc 100644 --- a/activerecord/lib/active_record/type/hash_lookup_type_map.rb +++ b/activerecord/lib/active_record/type/hash_lookup_type_map.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type class HashLookupTypeMap < TypeMap # :nodoc: diff --git a/activerecord/lib/active_record/type/internal/abstract_json.rb b/activerecord/lib/active_record/type/internal/abstract_json.rb deleted file mode 100644 index e19c5a14da..0000000000 --- a/activerecord/lib/active_record/type/internal/abstract_json.rb +++ /dev/null @@ -1,33 +0,0 @@ -module ActiveRecord - module Type - module Internal # :nodoc: - class AbstractJson < ActiveModel::Type::Value # :nodoc: - include ActiveModel::Type::Helpers::Mutable - - def type - :json - end - - def deserialize(value) - if value.is_a?(::String) - ::ActiveSupport::JSON.decode(value) rescue nil - else - value - end - end - - def serialize(value) - if value.nil? - nil - else - ::ActiveSupport::JSON.encode(value) - end - end - - def accessor - ActiveRecord::Store::StringKeyedHashAccessor - end - end - end - end -end diff --git a/activerecord/lib/active_record/type/internal/timezone.rb b/activerecord/lib/active_record/type/internal/timezone.rb index 947e06158a..3059755752 100644 --- a/activerecord/lib/active_record/type/internal/timezone.rb +++ b/activerecord/lib/active_record/type/internal/timezone.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type module Internal diff --git a/activerecord/lib/active_record/type/json.rb b/activerecord/lib/active_record/type/json.rb new file mode 100644 index 0000000000..3f9ff22796 --- /dev/null +++ b/activerecord/lib/active_record/type/json.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ActiveRecord + module Type + class Json < ActiveModel::Type::Value + include ActiveModel::Type::Helpers::Mutable + + def type + :json + end + + def deserialize(value) + return value unless value.is_a?(::String) + ActiveSupport::JSON.decode(value) rescue nil + end + + def serialize(value) + ActiveSupport::JSON.encode(value) unless value.nil? + end + + def changed_in_place?(raw_old_value, new_value) + deserialize(raw_old_value) != new_value + end + + def accessor + ActiveRecord::Store::StringKeyedHashAccessor + end + end + end +end diff --git a/activerecord/lib/active_record/type/serialized.rb b/activerecord/lib/active_record/type/serialized.rb index 6af05c1860..0a2f6cb9fb 100644 --- a/activerecord/lib/active_record/type/serialized.rb +++ b/activerecord/lib/active_record/type/serialized.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + module ActiveRecord module Type class Serialized < DelegateClass(ActiveModel::Type::Value) # :nodoc: + undef to_yaml if method_defined?(:to_yaml) + include ActiveModel::Type::Helpers::Mutable attr_reader :subtype, :coder @@ -47,6 +51,10 @@ module ActiveRecord end end + def force_equality?(value) + coder.respond_to?(:object_class) && value.is_a?(coder.object_class) + end + private def default_value?(value) diff --git a/activerecord/lib/active_record/type/text.rb b/activerecord/lib/active_record/type/text.rb index cb1949700a..6d19696671 100644 --- a/activerecord/lib/active_record/type/text.rb +++ b/activerecord/lib/active_record/type/text.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type class Text < ActiveModel::Type::String # :nodoc: diff --git a/activerecord/lib/active_record/type/time.rb b/activerecord/lib/active_record/type/time.rb index b9bac87c67..f4da1ecf2c 100644 --- a/activerecord/lib/active_record/type/time.rb +++ b/activerecord/lib/active_record/type/time.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type class Time < ActiveModel::Type::Time diff --git a/activerecord/lib/active_record/type/type_map.rb b/activerecord/lib/active_record/type/type_map.rb index 7bce82a1ff..fc40b460f0 100644 --- a/activerecord/lib/active_record/type/type_map.rb +++ b/activerecord/lib/active_record/type/type_map.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "concurrent/map" module ActiveRecord diff --git a/activerecord/lib/active_record/type/unsigned_integer.rb b/activerecord/lib/active_record/type/unsigned_integer.rb index 9ae0109f9f..4619528f81 100644 --- a/activerecord/lib/active_record/type/unsigned_integer.rb +++ b/activerecord/lib/active_record/type/unsigned_integer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Type class UnsignedInteger < ActiveModel::Type::Integer # :nodoc: diff --git a/activerecord/lib/active_record/type_caster.rb b/activerecord/lib/active_record/type_caster.rb index f1686e4913..2e5f45fa3d 100644 --- a/activerecord/lib/active_record/type_caster.rb +++ b/activerecord/lib/active_record/type_caster.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_record/type_caster/map" require "active_record/type_caster/connection" diff --git a/activerecord/lib/active_record/type_caster/connection.rb b/activerecord/lib/active_record/type_caster/connection.rb index 9f7bbe8843..7cf8181d8e 100644 --- a/activerecord/lib/active_record/type_caster/connection.rb +++ b/activerecord/lib/active_record/type_caster/connection.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module TypeCaster class Connection # :nodoc: @@ -12,15 +14,10 @@ module ActiveRecord connection.type_cast_from_column(column, value) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :table_name delegate :connection, to: :@klass - private - def column_for(attribute_name) if connection.schema_cache.data_source_exists?(table_name) connection.schema_cache.columns_hash(table_name)[attribute_name.to_s] diff --git a/activerecord/lib/active_record/type_caster/map.rb b/activerecord/lib/active_record/type_caster/map.rb index 9f79723125..663cdadb03 100644 --- a/activerecord/lib/active_record/type_caster/map.rb +++ b/activerecord/lib/active_record/type_caster/map.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module TypeCaster class Map # :nodoc: @@ -7,14 +9,11 @@ module ActiveRecord def type_cast_for_database(attr_name, value) return value if value.is_a?(Arel::Nodes::BindParam) - type = types.type_for_attribute(attr_name.to_s) + type = types.type_for_attribute(attr_name) type.serialize(value) end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - + private attr_reader :types end end diff --git a/activerecord/lib/active_record/validations.rb b/activerecord/lib/active_record/validations.rb index 9633f226f0..ca27a3f0ab 100644 --- a/activerecord/lib/active_record/validations.rb +++ b/activerecord/lib/active_record/validations.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord # = Active Record \RecordInvalid # diff --git a/activerecord/lib/active_record/validations/absence.rb b/activerecord/lib/active_record/validations/absence.rb index 641d041f3d..6afb9eabd2 100644 --- a/activerecord/lib/active_record/validations/absence.rb +++ b/activerecord/lib/active_record/validations/absence.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Validations class AbsenceValidator < ActiveModel::Validations::AbsenceValidator # :nodoc: diff --git a/activerecord/lib/active_record/validations/associated.rb b/activerecord/lib/active_record/validations/associated.rb index c695965d7b..3538aeec22 100644 --- a/activerecord/lib/active_record/validations/associated.rb +++ b/activerecord/lib/active_record/validations/associated.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Validations class AssociatedValidator < ActiveModel::EachValidator #:nodoc: diff --git a/activerecord/lib/active_record/validations/length.rb b/activerecord/lib/active_record/validations/length.rb index 0e0cebce4a..f47b14ae3a 100644 --- a/activerecord/lib/active_record/validations/length.rb +++ b/activerecord/lib/active_record/validations/length.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Validations class LengthValidator < ActiveModel::Validations::LengthValidator # :nodoc: diff --git a/activerecord/lib/active_record/validations/presence.rb b/activerecord/lib/active_record/validations/presence.rb index 7cfd55f516..75e97e1997 100644 --- a/activerecord/lib/active_record/validations/presence.rb +++ b/activerecord/lib/active_record/validations/presence.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Validations class PresenceValidator < ActiveModel::Validations::PresenceValidator # :nodoc: diff --git a/activerecord/lib/active_record/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb index 154cf5f1a4..5a1dbc8e53 100644 --- a/activerecord/lib/active_record/validations/uniqueness.rb +++ b/activerecord/lib/active_record/validations/uniqueness.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module Validations class UniquenessValidator < ActiveModel::EachValidator # :nodoc: @@ -6,6 +8,10 @@ module ActiveRecord raise ArgumentError, "#{options[:conditions]} was passed as :conditions but is not callable. " \ "Pass a callable instead: `conditions: -> { where(approved: true) }`" end + unless Array(options[:scope]).all? { |scope| scope.respond_to?(:to_sym) } + raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \ + "Pass a symbol or an array of symbols instead: `scope: :user_id`" + end super({ case_sensitive: true }.merge!(options)) @klass = options[:class] end @@ -17,7 +23,7 @@ module ActiveRecord relation = build_relation(finder_class, attribute, value) if record.persisted? if finder_class.primary_key - relation = relation.where.not(finder_class.primary_key => record.id_in_database || record.id) + relation = relation.where.not(finder_class.primary_key => record.id_in_database) else raise UnknownPrimaryKey.new(finder_class, "Can not validate uniqueness for persisted record without primary key.") end @@ -50,7 +56,33 @@ module ActiveRecord end def build_relation(klass, attribute, value) - klass.unscoped.where!({ attribute => value }, options) + if reflection = klass._reflect_on_association(attribute) + attribute = reflection.foreign_key + value = value.attributes[reflection.klass.primary_key] unless value.nil? + end + + if value.nil? + return klass.unscoped.where!(attribute => value) + end + + # the attribute may be an aliased attribute + if klass.attribute_alias?(attribute) + attribute = klass.attribute_alias(attribute) + end + + attribute_name = attribute.to_s + value = klass.predicate_builder.build_bind_attribute(attribute_name, value) + + table = klass.arel_table + column = klass.columns_hash[attribute_name] + + comparison = if !options[:case_sensitive] + # will use SQL LOWER function before comparison, unless it detects a case insensitive collation + klass.connection.case_insensitive_comparison(table, attribute, column, value) + else + klass.connection.case_sensitive_comparison(table, attribute, column, value) + end + klass.unscoped.where!(comparison) end def scope_relation(record, relation) @@ -173,9 +205,7 @@ module ActiveRecord # | # Boom! We now have a duplicate # | # title! # - # This could even happen if you use transactions with the 'serializable' - # isolation level. The best way to work around this problem is to add a unique - # index to the database table using + # The best way to work around this problem is to add a unique index to the database table using # {connection.add_index}[rdoc-ref:ConnectionAdapters::SchemaStatements#add_index]. # In the rare case that a race condition occurs, the database will guarantee # the field's uniqueness. @@ -187,7 +217,7 @@ module ActiveRecord # can catch it and restart the transaction (e.g. by telling the user # that the title already exists, and asking them to re-enter the title). # This technique is also known as - # {optimistic concurrency control}[http://en.wikipedia.org/wiki/Optimistic_concurrency_control]. + # {optimistic concurrency control}[https://en.wikipedia.org/wiki/Optimistic_concurrency_control]. # # The bundled ActiveRecord::ConnectionAdapters distinguish unique index # constraint errors from other types of database errors by throwing an diff --git a/activerecord/lib/active_record/version.rb b/activerecord/lib/active_record/version.rb index 146cfacc18..6b0d82d8fc 100644 --- a/activerecord/lib/active_record/version.rb +++ b/activerecord/lib/active_record/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "gem_version" module ActiveRecord diff --git a/activerecord/lib/arel.rb b/activerecord/lib/arel.rb new file mode 100644 index 0000000000..7d04e1cac6 --- /dev/null +++ b/activerecord/lib/arel.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "arel/errors" + +require "arel/crud" +require "arel/factory_methods" + +require "arel/expressions" +require "arel/predications" +require "arel/window_predications" +require "arel/math" +require "arel/alias_predication" +require "arel/order_predications" +require "arel/table" +require "arel/attributes" +require "arel/compatibility/wheres" + +require "arel/visitors" +require "arel/collectors/sql_string" + +require "arel/tree_manager" +require "arel/insert_manager" +require "arel/select_manager" +require "arel/update_manager" +require "arel/delete_manager" +require "arel/nodes" + +module Arel # :nodoc: all + VERSION = "10.0.0" + + def self.sql(raw_sql) + Arel::Nodes::SqlLiteral.new raw_sql + end + + def self.star + sql "*" + end + ## Convenience Alias + Node = Arel::Nodes::Node +end diff --git a/activerecord/lib/arel/alias_predication.rb b/activerecord/lib/arel/alias_predication.rb new file mode 100644 index 0000000000..4abbbb7ef6 --- /dev/null +++ b/activerecord/lib/arel/alias_predication.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module AliasPredication + def as(other) + Nodes::As.new self, Nodes::SqlLiteral.new(other) + end + end +end diff --git a/activerecord/lib/arel/attributes.rb b/activerecord/lib/arel/attributes.rb new file mode 100644 index 0000000000..35d586c948 --- /dev/null +++ b/activerecord/lib/arel/attributes.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "arel/attributes/attribute" + +module Arel # :nodoc: all + module Attributes + ### + # Factory method to wrap a raw database +column+ to an Arel Attribute. + def self.for(column) + case column.type + when :string, :text, :binary then String + when :integer then Integer + when :float then Float + when :decimal then Decimal + when :date, :datetime, :timestamp, :time then Time + when :boolean then Boolean + else + Undefined + end + end + end +end diff --git a/activerecord/lib/arel/attributes/attribute.rb b/activerecord/lib/arel/attributes/attribute.rb new file mode 100644 index 0000000000..ecf499a23e --- /dev/null +++ b/activerecord/lib/arel/attributes/attribute.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Attributes + class Attribute < Struct.new :relation, :name + include Arel::Expressions + include Arel::Predications + include Arel::AliasPredication + include Arel::OrderPredications + include Arel::Math + + ### + # Create a node for lowering this attribute + def lower + relation.lower self + end + + def type_cast_for_database(value) + relation.type_cast_for_database(name, value) + end + + def able_to_type_cast? + relation.able_to_type_cast? + end + end + + class String < Attribute; end + class Time < Attribute; end + class Boolean < Attribute; end + class Decimal < Attribute; end + class Float < Attribute; end + class Integer < Attribute; end + class Undefined < Attribute; end + end + + Attribute = Attributes::Attribute +end diff --git a/activerecord/lib/arel/collectors/bind.rb b/activerecord/lib/arel/collectors/bind.rb new file mode 100644 index 0000000000..6f8912575d --- /dev/null +++ b/activerecord/lib/arel/collectors/bind.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Collectors + class Bind + def initialize + @binds = [] + end + + def <<(str) + self + end + + def add_bind(bind) + @binds << bind + self + end + + def value + @binds + end + end + end +end diff --git a/activerecord/lib/arel/collectors/composite.rb b/activerecord/lib/arel/collectors/composite.rb new file mode 100644 index 0000000000..d040d8598d --- /dev/null +++ b/activerecord/lib/arel/collectors/composite.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Collectors + class Composite + def initialize(left, right) + @left = left + @right = right + end + + def <<(str) + left << str + right << str + self + end + + def add_bind(bind, &block) + left.add_bind bind, &block + right.add_bind bind, &block + self + end + + def value + [left.value, right.value] + end + + protected + + attr_reader :left, :right + end + end +end diff --git a/activerecord/lib/arel/collectors/plain_string.rb b/activerecord/lib/arel/collectors/plain_string.rb new file mode 100644 index 0000000000..687d7fbf2f --- /dev/null +++ b/activerecord/lib/arel/collectors/plain_string.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Collectors + class PlainString + def initialize + @str = "".dup + end + + def value + @str + end + + def <<(str) + @str << str + self + end + end + end +end diff --git a/activerecord/lib/arel/collectors/sql_string.rb b/activerecord/lib/arel/collectors/sql_string.rb new file mode 100644 index 0000000000..c293a89a74 --- /dev/null +++ b/activerecord/lib/arel/collectors/sql_string.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "arel/collectors/plain_string" + +module Arel # :nodoc: all + module Collectors + class SQLString < PlainString + def initialize(*) + super + @bind_index = 1 + end + + def add_bind(bind) + self << yield(@bind_index) + @bind_index += 1 + self + end + + def compile(bvs) + value + end + end + end +end diff --git a/activerecord/lib/arel/collectors/substitute_binds.rb b/activerecord/lib/arel/collectors/substitute_binds.rb new file mode 100644 index 0000000000..3f40eec8a8 --- /dev/null +++ b/activerecord/lib/arel/collectors/substitute_binds.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Collectors + class SubstituteBinds + def initialize(quoter, delegate_collector) + @quoter = quoter + @delegate = delegate_collector + end + + def <<(str) + delegate << str + self + end + + def add_bind(bind) + self << quoter.quote(bind) + end + + def value + delegate.value + end + + protected + + attr_reader :quoter, :delegate + end + end +end diff --git a/activerecord/lib/arel/compatibility/wheres.rb b/activerecord/lib/arel/compatibility/wheres.rb new file mode 100644 index 0000000000..c8a73f0dae --- /dev/null +++ b/activerecord/lib/arel/compatibility/wheres.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Compatibility # :nodoc: + class Wheres # :nodoc: + include Enumerable + + module Value # :nodoc: + attr_accessor :visitor + def value + visitor.accept self + end + + def name + super.to_sym + end + end + + def initialize(engine, collection) + @engine = engine + @collection = collection + end + + def each + to_sql = Visitors::ToSql.new @engine + + @collection.each { |c| + c.extend(Value) + c.visitor = to_sql + yield c + } + end + end + end +end diff --git a/activerecord/lib/arel/crud.rb b/activerecord/lib/arel/crud.rb new file mode 100644 index 0000000000..e8a563ca4a --- /dev/null +++ b/activerecord/lib/arel/crud.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + ### + # FIXME hopefully we can remove this + module Crud + def compile_update(values, pk) + um = UpdateManager.new + + if Nodes::SqlLiteral === values + relation = @ctx.from + else + relation = values.first.first.relation + end + um.key = pk + um.table relation + um.set values + um.take @ast.limit.expr if @ast.limit + um.order(*@ast.orders) + um.wheres = @ctx.wheres + um + end + + def compile_insert(values) + im = create_insert + im.insert values + im + end + + def create_insert + InsertManager.new + end + + def compile_delete + dm = DeleteManager.new + dm.take @ast.limit.expr if @ast.limit + dm.wheres = @ctx.wheres + dm.from @ctx.froms + dm + end + end +end diff --git a/activerecord/lib/arel/delete_manager.rb b/activerecord/lib/arel/delete_manager.rb new file mode 100644 index 0000000000..2def581009 --- /dev/null +++ b/activerecord/lib/arel/delete_manager.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class DeleteManager < Arel::TreeManager + def initialize + super + @ast = Nodes::DeleteStatement.new + @ctx = @ast + end + + def from(relation) + @ast.relation = relation + self + end + + def take(limit) + @ast.limit = Nodes::Limit.new(Nodes.build_quoted(limit)) if limit + self + end + + def wheres=(list) + @ast.wheres = list + end + end +end diff --git a/activerecord/lib/arel/errors.rb b/activerecord/lib/arel/errors.rb new file mode 100644 index 0000000000..2f8d5e3c02 --- /dev/null +++ b/activerecord/lib/arel/errors.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class ArelError < StandardError + end + + class EmptyJoinError < ArelError + end +end diff --git a/activerecord/lib/arel/expressions.rb b/activerecord/lib/arel/expressions.rb new file mode 100644 index 0000000000..da8afb338c --- /dev/null +++ b/activerecord/lib/arel/expressions.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Expressions + def count(distinct = false) + Nodes::Count.new [self], distinct + end + + def sum + Nodes::Sum.new [self] + end + + def maximum + Nodes::Max.new [self] + end + + def minimum + Nodes::Min.new [self] + end + + def average + Nodes::Avg.new [self] + end + + def extract(field) + Nodes::Extract.new [self], field + end + end +end diff --git a/activerecord/lib/arel/factory_methods.rb b/activerecord/lib/arel/factory_methods.rb new file mode 100644 index 0000000000..b828bc274e --- /dev/null +++ b/activerecord/lib/arel/factory_methods.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + ### + # Methods for creating various nodes + module FactoryMethods + def create_true + Arel::Nodes::True.new + end + + def create_false + Arel::Nodes::False.new + end + + def create_table_alias(relation, name) + Nodes::TableAlias.new(relation, name) + end + + def create_join(to, constraint = nil, klass = Nodes::InnerJoin) + klass.new(to, constraint) + end + + def create_string_join(to) + create_join to, nil, Nodes::StringJoin + end + + def create_and(clauses) + Nodes::And.new clauses + end + + def create_on(expr) + Nodes::On.new expr + end + + def grouping(expr) + Nodes::Grouping.new expr + end + + ### + # Create a LOWER() function + def lower(column) + Nodes::NamedFunction.new "LOWER", [Nodes.build_quoted(column)] + end + end +end diff --git a/activerecord/lib/arel/insert_manager.rb b/activerecord/lib/arel/insert_manager.rb new file mode 100644 index 0000000000..c90fc33a48 --- /dev/null +++ b/activerecord/lib/arel/insert_manager.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class InsertManager < Arel::TreeManager + def initialize + super + @ast = Nodes::InsertStatement.new + end + + def into(table) + @ast.relation = table + self + end + + def columns; @ast.columns end + def values=(val); @ast.values = val; end + + def select(select) + @ast.select = select + end + + def insert(fields) + return if fields.empty? + + if String === fields + @ast.values = Nodes::SqlLiteral.new(fields) + else + @ast.relation ||= fields.first.first.relation + + values = [] + + fields.each do |column, value| + @ast.columns << column + values << value + end + @ast.values = create_values values, @ast.columns + end + self + end + + def create_values(values, columns) + Nodes::Values.new values, columns + end + + def create_values_list(rows) + Nodes::ValuesList.new(rows) + end + end +end diff --git a/activerecord/lib/arel/math.rb b/activerecord/lib/arel/math.rb new file mode 100644 index 0000000000..2359f13148 --- /dev/null +++ b/activerecord/lib/arel/math.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Math + def *(other) + Arel::Nodes::Multiplication.new(self, other) + end + + def +(other) + Arel::Nodes::Grouping.new(Arel::Nodes::Addition.new(self, other)) + end + + def -(other) + Arel::Nodes::Grouping.new(Arel::Nodes::Subtraction.new(self, other)) + end + + def /(other) + Arel::Nodes::Division.new(self, other) + end + + def &(other) + Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseAnd.new(self, other)) + end + + def |(other) + Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseOr.new(self, other)) + end + + def ^(other) + Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseXor.new(self, other)) + end + + def <<(other) + Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseShiftLeft.new(self, other)) + end + + def >>(other) + Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseShiftRight.new(self, other)) + end + + def ~@ + Arel::Nodes::BitwiseNot.new(self) + end + end +end diff --git a/activerecord/lib/arel/nodes.rb b/activerecord/lib/arel/nodes.rb new file mode 100644 index 0000000000..5af0e532e2 --- /dev/null +++ b/activerecord/lib/arel/nodes.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# node +require "arel/nodes/node" +require "arel/nodes/node_expression" +require "arel/nodes/select_statement" +require "arel/nodes/select_core" +require "arel/nodes/insert_statement" +require "arel/nodes/update_statement" +require "arel/nodes/bind_param" + +# terminal + +require "arel/nodes/terminal" +require "arel/nodes/true" +require "arel/nodes/false" + +# unary +require "arel/nodes/unary" +require "arel/nodes/grouping" +require "arel/nodes/ascending" +require "arel/nodes/descending" +require "arel/nodes/unqualified_column" +require "arel/nodes/with" + +# binary +require "arel/nodes/binary" +require "arel/nodes/equality" +require "arel/nodes/in" # Why is this subclassed from equality? +require "arel/nodes/join_source" +require "arel/nodes/delete_statement" +require "arel/nodes/table_alias" +require "arel/nodes/infix_operation" +require "arel/nodes/unary_operation" +require "arel/nodes/over" +require "arel/nodes/matches" +require "arel/nodes/regexp" + +# nary +require "arel/nodes/and" + +# function +# FIXME: Function + Alias can be rewritten as a Function and Alias node. +# We should make Function a Unary node and deprecate the use of "aliaz" +require "arel/nodes/function" +require "arel/nodes/count" +require "arel/nodes/extract" +require "arel/nodes/values" +require "arel/nodes/values_list" +require "arel/nodes/named_function" + +# windows +require "arel/nodes/window" + +# conditional expressions +require "arel/nodes/case" + +# joins +require "arel/nodes/full_outer_join" +require "arel/nodes/inner_join" +require "arel/nodes/outer_join" +require "arel/nodes/right_outer_join" +require "arel/nodes/string_join" + +require "arel/nodes/sql_literal" + +require "arel/nodes/casted" diff --git a/activerecord/lib/arel/nodes/and.rb b/activerecord/lib/arel/nodes/and.rb new file mode 100644 index 0000000000..c530a77bfb --- /dev/null +++ b/activerecord/lib/arel/nodes/and.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class And < Arel::Nodes::Node + attr_reader :children + + def initialize(children) + super() + @children = children + end + + def left + children.first + end + + def right + children[1] + end + + def hash + children.hash + end + + def eql?(other) + self.class == other.class && + self.children == other.children + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/ascending.rb b/activerecord/lib/arel/nodes/ascending.rb new file mode 100644 index 0000000000..8b617f4df5 --- /dev/null +++ b/activerecord/lib/arel/nodes/ascending.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Ascending < Ordering + def reverse + Descending.new(expr) + end + + def direction + :asc + end + + def ascending? + true + end + + def descending? + false + end + end + end +end diff --git a/activerecord/lib/arel/nodes/binary.rb b/activerecord/lib/arel/nodes/binary.rb new file mode 100644 index 0000000000..e184e99c73 --- /dev/null +++ b/activerecord/lib/arel/nodes/binary.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Binary < Arel::Nodes::NodeExpression + attr_accessor :left, :right + + def initialize(left, right) + super() + @left = left + @right = right + end + + def initialize_copy(other) + super + @left = @left.clone if @left + @right = @right.clone if @right + end + + def hash + [self.class, @left, @right].hash + end + + def eql?(other) + self.class == other.class && + self.left == other.left && + self.right == other.right + end + alias :== :eql? + end + + %w{ + As + Assignment + Between + GreaterThan + GreaterThanOrEqual + Join + LessThan + LessThanOrEqual + NotEqual + NotIn + Or + Union + UnionAll + Intersect + Except + }.each do |name| + const_set name, Class.new(Binary) + end + end +end diff --git a/activerecord/lib/arel/nodes/bind_param.rb b/activerecord/lib/arel/nodes/bind_param.rb new file mode 100644 index 0000000000..91e9b2b70f --- /dev/null +++ b/activerecord/lib/arel/nodes/bind_param.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class BindParam < Node + attr_reader :value + + def initialize(value) + @value = value + super() + end + + def hash + [self.class, self.value].hash + end + + def eql?(other) + other.is_a?(BindParam) && + value == other.value + end + alias :== :eql? + + def nil? + value.nil? + end + end + end +end diff --git a/activerecord/lib/arel/nodes/case.rb b/activerecord/lib/arel/nodes/case.rb new file mode 100644 index 0000000000..654a54825e --- /dev/null +++ b/activerecord/lib/arel/nodes/case.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Case < Arel::Nodes::Node + attr_accessor :case, :conditions, :default + + def initialize(expression = nil, default = nil) + @case = expression + @conditions = [] + @default = default + end + + def when(condition, expression = nil) + @conditions << When.new(Nodes.build_quoted(condition), expression) + self + end + + def then(expression) + @conditions.last.right = Nodes.build_quoted(expression) + self + end + + def else(expression) + @default = Else.new Nodes.build_quoted(expression) + self + end + + def initialize_copy(other) + super + @case = @case.clone if @case + @conditions = @conditions.map { |x| x.clone } + @default = @default.clone if @default + end + + def hash + [@case, @conditions, @default].hash + end + + def eql?(other) + self.class == other.class && + self.case == other.case && + self.conditions == other.conditions && + self.default == other.default + end + alias :== :eql? + end + + class When < Binary # :nodoc: + end + + class Else < Unary # :nodoc: + end + end +end diff --git a/activerecord/lib/arel/nodes/casted.rb b/activerecord/lib/arel/nodes/casted.rb new file mode 100644 index 0000000000..c1e6e97d6d --- /dev/null +++ b/activerecord/lib/arel/nodes/casted.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Casted < Arel::Nodes::NodeExpression # :nodoc: + attr_reader :val, :attribute + def initialize(val, attribute) + @val = val + @attribute = attribute + super() + end + + def nil?; @val.nil?; end + + def hash + [self.class, val, attribute].hash + end + + def eql?(other) + self.class == other.class && + self.val == other.val && + self.attribute == other.attribute + end + alias :== :eql? + end + + class Quoted < Arel::Nodes::Unary # :nodoc: + alias :val :value + def nil?; val.nil?; end + end + + def self.build_quoted(other, attribute = nil) + case other + when Arel::Nodes::Node, Arel::Attributes::Attribute, Arel::Table, Arel::Nodes::BindParam, Arel::SelectManager, Arel::Nodes::Quoted, Arel::Nodes::SqlLiteral + other + else + case attribute + when Arel::Attributes::Attribute + Casted.new other, attribute + else + Quoted.new other + end + end + end + end +end diff --git a/activerecord/lib/arel/nodes/count.rb b/activerecord/lib/arel/nodes/count.rb new file mode 100644 index 0000000000..880464639d --- /dev/null +++ b/activerecord/lib/arel/nodes/count.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Count < Arel::Nodes::Function + def initialize(expr, distinct = false, aliaz = nil) + super(expr, aliaz) + @distinct = distinct + end + end + end +end diff --git a/activerecord/lib/arel/nodes/delete_statement.rb b/activerecord/lib/arel/nodes/delete_statement.rb new file mode 100644 index 0000000000..eaac05e2f6 --- /dev/null +++ b/activerecord/lib/arel/nodes/delete_statement.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class DeleteStatement < Arel::Nodes::Node + attr_accessor :left, :right + attr_accessor :limit + + alias :relation :left + alias :relation= :left= + alias :wheres :right + alias :wheres= :right= + + def initialize(relation = nil, wheres = []) + super() + @left = relation + @right = wheres + end + + def initialize_copy(other) + super + @left = @left.clone if @left + @right = @right.clone if @right + end + + def hash + [self.class, @left, @right].hash + end + + def eql?(other) + self.class == other.class && + self.left == other.left && + self.right == other.right + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/descending.rb b/activerecord/lib/arel/nodes/descending.rb new file mode 100644 index 0000000000..f3f6992ca8 --- /dev/null +++ b/activerecord/lib/arel/nodes/descending.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Descending < Ordering + def reverse + Ascending.new(expr) + end + + def direction + :desc + end + + def ascending? + false + end + + def descending? + true + end + end + end +end diff --git a/activerecord/lib/arel/nodes/equality.rb b/activerecord/lib/arel/nodes/equality.rb new file mode 100644 index 0000000000..2aa85a977e --- /dev/null +++ b/activerecord/lib/arel/nodes/equality.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Equality < Arel::Nodes::Binary + def operator; :== end + alias :operand1 :left + alias :operand2 :right + end + end +end diff --git a/activerecord/lib/arel/nodes/extract.rb b/activerecord/lib/arel/nodes/extract.rb new file mode 100644 index 0000000000..5799ee9b8f --- /dev/null +++ b/activerecord/lib/arel/nodes/extract.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Extract < Arel::Nodes::Unary + attr_accessor :field + + def initialize(expr, field) + super(expr) + @field = field + end + + def hash + super ^ @field.hash + end + + def eql?(other) + super && + self.field == other.field + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/false.rb b/activerecord/lib/arel/nodes/false.rb new file mode 100644 index 0000000000..1e5bf04be5 --- /dev/null +++ b/activerecord/lib/arel/nodes/false.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class False < Arel::Nodes::NodeExpression + def hash + self.class.hash + end + + def eql?(other) + self.class == other.class + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/full_outer_join.rb b/activerecord/lib/arel/nodes/full_outer_join.rb new file mode 100644 index 0000000000..91bb81f2e3 --- /dev/null +++ b/activerecord/lib/arel/nodes/full_outer_join.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class FullOuterJoin < Arel::Nodes::Join + end + end +end diff --git a/activerecord/lib/arel/nodes/function.rb b/activerecord/lib/arel/nodes/function.rb new file mode 100644 index 0000000000..0a439b39f5 --- /dev/null +++ b/activerecord/lib/arel/nodes/function.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Function < Arel::Nodes::NodeExpression + include Arel::WindowPredications + attr_accessor :expressions, :alias, :distinct + + def initialize(expr, aliaz = nil) + super() + @expressions = expr + @alias = aliaz && SqlLiteral.new(aliaz) + @distinct = false + end + + def as(aliaz) + self.alias = SqlLiteral.new(aliaz) + self + end + + def hash + [@expressions, @alias, @distinct].hash + end + + def eql?(other) + self.class == other.class && + self.expressions == other.expressions && + self.alias == other.alias && + self.distinct == other.distinct + end + alias :== :eql? + end + + %w{ + Sum + Exists + Max + Min + Avg + }.each do |name| + const_set(name, Class.new(Function)) + end + end +end diff --git a/activerecord/lib/arel/nodes/grouping.rb b/activerecord/lib/arel/nodes/grouping.rb new file mode 100644 index 0000000000..4d0bd69d4d --- /dev/null +++ b/activerecord/lib/arel/nodes/grouping.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Grouping < Unary + end + end +end diff --git a/activerecord/lib/arel/nodes/in.rb b/activerecord/lib/arel/nodes/in.rb new file mode 100644 index 0000000000..2be45d6f99 --- /dev/null +++ b/activerecord/lib/arel/nodes/in.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class In < Equality + end + end +end diff --git a/activerecord/lib/arel/nodes/infix_operation.rb b/activerecord/lib/arel/nodes/infix_operation.rb new file mode 100644 index 0000000000..bc7e20dcc6 --- /dev/null +++ b/activerecord/lib/arel/nodes/infix_operation.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class InfixOperation < Binary + include Arel::Expressions + include Arel::Predications + include Arel::OrderPredications + include Arel::AliasPredication + include Arel::Math + + attr_reader :operator + + def initialize(operator, left, right) + super(left, right) + @operator = operator + end + end + + class Multiplication < InfixOperation + def initialize(left, right) + super(:*, left, right) + end + end + + class Division < InfixOperation + def initialize(left, right) + super(:/, left, right) + end + end + + class Addition < InfixOperation + def initialize(left, right) + super(:+, left, right) + end + end + + class Subtraction < InfixOperation + def initialize(left, right) + super(:-, left, right) + end + end + + class Concat < InfixOperation + def initialize(left, right) + super("||", left, right) + end + end + + class BitwiseAnd < InfixOperation + def initialize(left, right) + super(:&, left, right) + end + end + + class BitwiseOr < InfixOperation + def initialize(left, right) + super(:|, left, right) + end + end + + class BitwiseXor < InfixOperation + def initialize(left, right) + super(:^, left, right) + end + end + + class BitwiseShiftLeft < InfixOperation + def initialize(left, right) + super(:<<, left, right) + end + end + + class BitwiseShiftRight < InfixOperation + def initialize(left, right) + super(:>>, left, right) + end + end + end +end diff --git a/activerecord/lib/arel/nodes/inner_join.rb b/activerecord/lib/arel/nodes/inner_join.rb new file mode 100644 index 0000000000..519fafad09 --- /dev/null +++ b/activerecord/lib/arel/nodes/inner_join.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class InnerJoin < Arel::Nodes::Join + end + end +end diff --git a/activerecord/lib/arel/nodes/insert_statement.rb b/activerecord/lib/arel/nodes/insert_statement.rb new file mode 100644 index 0000000000..d28fd1f6c8 --- /dev/null +++ b/activerecord/lib/arel/nodes/insert_statement.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class InsertStatement < Arel::Nodes::Node + attr_accessor :relation, :columns, :values, :select + + def initialize + super() + @relation = nil + @columns = [] + @values = nil + @select = nil + end + + def initialize_copy(other) + super + @columns = @columns.clone + @values = @values.clone if @values + @select = @select.clone if @select + end + + def hash + [@relation, @columns, @values, @select].hash + end + + def eql?(other) + self.class == other.class && + self.relation == other.relation && + self.columns == other.columns && + self.select == other.select && + self.values == other.values + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/join_source.rb b/activerecord/lib/arel/nodes/join_source.rb new file mode 100644 index 0000000000..abf0944623 --- /dev/null +++ b/activerecord/lib/arel/nodes/join_source.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + ### + # Class that represents a join source + # + # http://www.sqlite.org/syntaxdiagrams.html#join-source + + class JoinSource < Arel::Nodes::Binary + def initialize(single_source, joinop = []) + super + end + + def empty? + !left && right.empty? + end + end + end +end diff --git a/activerecord/lib/arel/nodes/matches.rb b/activerecord/lib/arel/nodes/matches.rb new file mode 100644 index 0000000000..fd5734f4bd --- /dev/null +++ b/activerecord/lib/arel/nodes/matches.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Matches < Binary + attr_reader :escape + attr_accessor :case_sensitive + + def initialize(left, right, escape = nil, case_sensitive = false) + super(left, right) + @escape = escape && Nodes.build_quoted(escape) + @case_sensitive = case_sensitive + end + end + + class DoesNotMatch < Matches; end + end +end diff --git a/activerecord/lib/arel/nodes/named_function.rb b/activerecord/lib/arel/nodes/named_function.rb new file mode 100644 index 0000000000..126462d6d6 --- /dev/null +++ b/activerecord/lib/arel/nodes/named_function.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class NamedFunction < Arel::Nodes::Function + attr_accessor :name + + def initialize(name, expr, aliaz = nil) + super(expr, aliaz) + @name = name + end + + def hash + super ^ @name.hash + end + + def eql?(other) + super && self.name == other.name + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/node.rb b/activerecord/lib/arel/nodes/node.rb new file mode 100644 index 0000000000..2b9b1e9828 --- /dev/null +++ b/activerecord/lib/arel/nodes/node.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + ### + # Abstract base class for all AST nodes + class Node + include Arel::FactoryMethods + include Enumerable + + if $DEBUG + def _caller + @caller + end + + def initialize + @caller = caller.dup + end + end + + ### + # Factory method to create a Nodes::Not node that has the recipient of + # the caller as a child. + def not + Nodes::Not.new self + end + + ### + # Factory method to create a Nodes::Grouping node that has an Nodes::Or + # node as a child. + def or(right) + Nodes::Grouping.new Nodes::Or.new(self, right) + end + + ### + # Factory method to create an Nodes::And node. + def and(right) + Nodes::And.new [self, right] + end + + # FIXME: this method should go away. I don't like people calling + # to_sql on non-head nodes. This forces us to walk the AST until we + # can find a node that has a "relation" member. + # + # Maybe we should just use `Table.engine`? :'( + def to_sql(engine = Table.engine) + collector = Arel::Collectors::SQLString.new + collector = engine.connection.visitor.accept self, collector + collector.value + end + + # Iterate through AST, nodes will be yielded depth-first + def each(&block) + return enum_for(:each) unless block_given? + + ::Arel::Visitors::DepthFirst.new(block).accept self + end + end + end +end diff --git a/activerecord/lib/arel/nodes/node_expression.rb b/activerecord/lib/arel/nodes/node_expression.rb new file mode 100644 index 0000000000..cbcfaba37c --- /dev/null +++ b/activerecord/lib/arel/nodes/node_expression.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class NodeExpression < Arel::Nodes::Node + include Arel::Expressions + include Arel::Predications + include Arel::AliasPredication + include Arel::OrderPredications + include Arel::Math + end + end +end diff --git a/activerecord/lib/arel/nodes/outer_join.rb b/activerecord/lib/arel/nodes/outer_join.rb new file mode 100644 index 0000000000..0a3042be61 --- /dev/null +++ b/activerecord/lib/arel/nodes/outer_join.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class OuterJoin < Arel::Nodes::Join + end + end +end diff --git a/activerecord/lib/arel/nodes/over.rb b/activerecord/lib/arel/nodes/over.rb new file mode 100644 index 0000000000..91176764a9 --- /dev/null +++ b/activerecord/lib/arel/nodes/over.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Over < Binary + include Arel::AliasPredication + + def initialize(left, right = nil) + super(left, right) + end + + def operator; "OVER" end + end + end +end diff --git a/activerecord/lib/arel/nodes/regexp.rb b/activerecord/lib/arel/nodes/regexp.rb new file mode 100644 index 0000000000..7c25095569 --- /dev/null +++ b/activerecord/lib/arel/nodes/regexp.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Regexp < Binary + attr_accessor :case_sensitive + + def initialize(left, right, case_sensitive = true) + super(left, right) + @case_sensitive = case_sensitive + end + end + + class NotRegexp < Regexp; end + end +end diff --git a/activerecord/lib/arel/nodes/right_outer_join.rb b/activerecord/lib/arel/nodes/right_outer_join.rb new file mode 100644 index 0000000000..04ed4aaa78 --- /dev/null +++ b/activerecord/lib/arel/nodes/right_outer_join.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class RightOuterJoin < Arel::Nodes::Join + end + end +end diff --git a/activerecord/lib/arel/nodes/select_core.rb b/activerecord/lib/arel/nodes/select_core.rb new file mode 100644 index 0000000000..2defe61974 --- /dev/null +++ b/activerecord/lib/arel/nodes/select_core.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class SelectCore < Arel::Nodes::Node + attr_accessor :top, :projections, :wheres, :groups, :windows + attr_accessor :havings, :source, :set_quantifier + + def initialize + super() + @source = JoinSource.new nil + @top = nil + + # https://ronsavage.github.io/SQL/sql-92.bnf.html#set%20quantifier + @set_quantifier = nil + @projections = [] + @wheres = [] + @groups = [] + @havings = [] + @windows = [] + end + + def from + @source.left + end + + def from=(value) + @source.left = value + end + + alias :froms= :from= + alias :froms :from + + def initialize_copy(other) + super + @source = @source.clone if @source + @projections = @projections.clone + @wheres = @wheres.clone + @groups = @groups.clone + @havings = @havings.clone + @windows = @windows.clone + end + + def hash + [ + @source, @top, @set_quantifier, @projections, + @wheres, @groups, @havings, @windows + ].hash + end + + def eql?(other) + self.class == other.class && + self.source == other.source && + self.top == other.top && + self.set_quantifier == other.set_quantifier && + self.projections == other.projections && + self.wheres == other.wheres && + self.groups == other.groups && + self.havings == other.havings && + self.windows == other.windows + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/select_statement.rb b/activerecord/lib/arel/nodes/select_statement.rb new file mode 100644 index 0000000000..eff5dad939 --- /dev/null +++ b/activerecord/lib/arel/nodes/select_statement.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class SelectStatement < Arel::Nodes::NodeExpression + attr_reader :cores + attr_accessor :limit, :orders, :lock, :offset, :with + + def initialize(cores = [SelectCore.new]) + super() + @cores = cores + @orders = [] + @limit = nil + @lock = nil + @offset = nil + @with = nil + end + + def initialize_copy(other) + super + @cores = @cores.map { |x| x.clone } + @orders = @orders.map { |x| x.clone } + end + + def hash + [@cores, @orders, @limit, @lock, @offset, @with].hash + end + + def eql?(other) + self.class == other.class && + self.cores == other.cores && + self.orders == other.orders && + self.limit == other.limit && + self.lock == other.lock && + self.offset == other.offset && + self.with == other.with + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/sql_literal.rb b/activerecord/lib/arel/nodes/sql_literal.rb new file mode 100644 index 0000000000..d25a8521b7 --- /dev/null +++ b/activerecord/lib/arel/nodes/sql_literal.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class SqlLiteral < String + include Arel::Expressions + include Arel::Predications + include Arel::AliasPredication + include Arel::OrderPredications + + def encode_with(coder) + coder.scalar = self.to_s + end + end + end +end diff --git a/activerecord/lib/arel/nodes/string_join.rb b/activerecord/lib/arel/nodes/string_join.rb new file mode 100644 index 0000000000..86027fcab7 --- /dev/null +++ b/activerecord/lib/arel/nodes/string_join.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class StringJoin < Arel::Nodes::Join + def initialize(left, right = nil) + super + end + end + end +end diff --git a/activerecord/lib/arel/nodes/table_alias.rb b/activerecord/lib/arel/nodes/table_alias.rb new file mode 100644 index 0000000000..f95ca16a3d --- /dev/null +++ b/activerecord/lib/arel/nodes/table_alias.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class TableAlias < Arel::Nodes::Binary + alias :name :right + alias :relation :left + alias :table_alias :name + + def [](name) + Attribute.new(self, name) + end + + def table_name + relation.respond_to?(:name) ? relation.name : name + end + + def type_cast_for_database(*args) + relation.type_cast_for_database(*args) + end + + def able_to_type_cast? + relation.respond_to?(:able_to_type_cast?) && relation.able_to_type_cast? + end + end + end +end diff --git a/activerecord/lib/arel/nodes/terminal.rb b/activerecord/lib/arel/nodes/terminal.rb new file mode 100644 index 0000000000..d84c453f1a --- /dev/null +++ b/activerecord/lib/arel/nodes/terminal.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Distinct < Arel::Nodes::NodeExpression + def hash + self.class.hash + end + + def eql?(other) + self.class == other.class + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/true.rb b/activerecord/lib/arel/nodes/true.rb new file mode 100644 index 0000000000..c891012969 --- /dev/null +++ b/activerecord/lib/arel/nodes/true.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class True < Arel::Nodes::NodeExpression + def hash + self.class.hash + end + + def eql?(other) + self.class == other.class + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/unary.rb b/activerecord/lib/arel/nodes/unary.rb new file mode 100644 index 0000000000..a3c0045897 --- /dev/null +++ b/activerecord/lib/arel/nodes/unary.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Unary < Arel::Nodes::NodeExpression + attr_accessor :expr + alias :value :expr + + def initialize(expr) + super() + @expr = expr + end + + def hash + @expr.hash + end + + def eql?(other) + self.class == other.class && + self.expr == other.expr + end + alias :== :eql? + end + + %w{ + Bin + Cube + DistinctOn + Group + GroupingElement + GroupingSet + Lateral + Limit + Lock + Not + Offset + On + Ordering + RollUp + Top + }.each do |name| + const_set(name, Class.new(Unary)) + end + end +end diff --git a/activerecord/lib/arel/nodes/unary_operation.rb b/activerecord/lib/arel/nodes/unary_operation.rb new file mode 100644 index 0000000000..524282ac84 --- /dev/null +++ b/activerecord/lib/arel/nodes/unary_operation.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class UnaryOperation < Unary + attr_reader :operator + + def initialize(operator, operand) + super(operand) + @operator = operator + end + end + + class BitwiseNot < UnaryOperation + def initialize(operand) + super(:~, operand) + end + end + end +end diff --git a/activerecord/lib/arel/nodes/unqualified_column.rb b/activerecord/lib/arel/nodes/unqualified_column.rb new file mode 100644 index 0000000000..7c3e0720d7 --- /dev/null +++ b/activerecord/lib/arel/nodes/unqualified_column.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class UnqualifiedColumn < Arel::Nodes::Unary + alias :attribute :expr + alias :attribute= :expr= + + def relation + @expr.relation + end + + def column + @expr.column + end + + def name + @expr.name + end + end + end +end diff --git a/activerecord/lib/arel/nodes/update_statement.rb b/activerecord/lib/arel/nodes/update_statement.rb new file mode 100644 index 0000000000..5184b1180f --- /dev/null +++ b/activerecord/lib/arel/nodes/update_statement.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class UpdateStatement < Arel::Nodes::Node + attr_accessor :relation, :wheres, :values, :orders, :limit + attr_accessor :key + + def initialize + @relation = nil + @wheres = [] + @values = [] + @orders = [] + @limit = nil + @key = nil + end + + def initialize_copy(other) + super + @wheres = @wheres.clone + @values = @values.clone + end + + def hash + [@relation, @wheres, @values, @orders, @limit, @key].hash + end + + def eql?(other) + self.class == other.class && + self.relation == other.relation && + self.wheres == other.wheres && + self.values == other.values && + self.orders == other.orders && + self.limit == other.limit && + self.key == other.key + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/values.rb b/activerecord/lib/arel/nodes/values.rb new file mode 100644 index 0000000000..650248dc04 --- /dev/null +++ b/activerecord/lib/arel/nodes/values.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Values < Arel::Nodes::Binary + alias :expressions :left + alias :expressions= :left= + alias :columns :right + alias :columns= :right= + + def initialize(exprs, columns = []) + super + end + end + end +end diff --git a/activerecord/lib/arel/nodes/values_list.rb b/activerecord/lib/arel/nodes/values_list.rb new file mode 100644 index 0000000000..27109848e4 --- /dev/null +++ b/activerecord/lib/arel/nodes/values_list.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class ValuesList < Node + attr_reader :rows + + def initialize(rows) + @rows = rows + super() + end + + def hash + @rows.hash + end + + def eql?(other) + self.class == other.class && + self.rows == other.rows + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/window.rb b/activerecord/lib/arel/nodes/window.rb new file mode 100644 index 0000000000..4916fc7fbe --- /dev/null +++ b/activerecord/lib/arel/nodes/window.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Window < Arel::Nodes::Node + attr_accessor :orders, :framing, :partitions + + def initialize + @orders = [] + @partitions = [] + @framing = nil + end + + def order(*expr) + # FIXME: We SHOULD NOT be converting these to SqlLiteral automatically + @orders.concat expr.map { |x| + String === x || Symbol === x ? Nodes::SqlLiteral.new(x.to_s) : x + } + self + end + + def partition(*expr) + # FIXME: We SHOULD NOT be converting these to SqlLiteral automatically + @partitions.concat expr.map { |x| + String === x || Symbol === x ? Nodes::SqlLiteral.new(x.to_s) : x + } + self + end + + def frame(expr) + @framing = expr + end + + def rows(expr = nil) + if @framing + Rows.new(expr) + else + frame(Rows.new(expr)) + end + end + + def range(expr = nil) + if @framing + Range.new(expr) + else + frame(Range.new(expr)) + end + end + + def initialize_copy(other) + super + @orders = @orders.map { |x| x.clone } + end + + def hash + [@orders, @framing].hash + end + + def eql?(other) + self.class == other.class && + self.orders == other.orders && + self.framing == other.framing && + self.partitions == other.partitions + end + alias :== :eql? + end + + class NamedWindow < Window + attr_accessor :name + + def initialize(name) + super() + @name = name + end + + def initialize_copy(other) + super + @name = other.name.clone + end + + def hash + super ^ @name.hash + end + + def eql?(other) + super && self.name == other.name + end + alias :== :eql? + end + + class Rows < Unary + def initialize(expr = nil) + super(expr) + end + end + + class Range < Unary + def initialize(expr = nil) + super(expr) + end + end + + class CurrentRow < Node + def hash + self.class.hash + end + + def eql?(other) + self.class == other.class + end + alias :== :eql? + end + + class Preceding < Unary + def initialize(expr = nil) + super(expr) + end + end + + class Following < Unary + def initialize(expr = nil) + super(expr) + end + end + end +end diff --git a/activerecord/lib/arel/nodes/with.rb b/activerecord/lib/arel/nodes/with.rb new file mode 100644 index 0000000000..157bdcaa08 --- /dev/null +++ b/activerecord/lib/arel/nodes/with.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class With < Arel::Nodes::Unary + alias children expr + end + + class WithRecursive < With; end + end +end diff --git a/activerecord/lib/arel/order_predications.rb b/activerecord/lib/arel/order_predications.rb new file mode 100644 index 0000000000..d785bbba92 --- /dev/null +++ b/activerecord/lib/arel/order_predications.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module OrderPredications + def asc + Nodes::Ascending.new self + end + + def desc + Nodes::Descending.new self + end + end +end diff --git a/activerecord/lib/arel/predications.rb b/activerecord/lib/arel/predications.rb new file mode 100644 index 0000000000..e83a6f162f --- /dev/null +++ b/activerecord/lib/arel/predications.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Predications + def not_eq(other) + Nodes::NotEqual.new self, quoted_node(other) + end + + def not_eq_any(others) + grouping_any :not_eq, others + end + + def not_eq_all(others) + grouping_all :not_eq, others + end + + def eq(other) + Nodes::Equality.new self, quoted_node(other) + end + + def eq_any(others) + grouping_any :eq, others + end + + def eq_all(others) + grouping_all :eq, quoted_array(others) + end + + def between(other) + if equals_quoted?(other.begin, -Float::INFINITY) + if equals_quoted?(other.end, Float::INFINITY) + not_in([]) + elsif other.exclude_end? + lt(other.end) + else + lteq(other.end) + end + elsif equals_quoted?(other.end, Float::INFINITY) + gteq(other.begin) + elsif other.exclude_end? + gteq(other.begin).and(lt(other.end)) + else + left = quoted_node(other.begin) + right = quoted_node(other.end) + Nodes::Between.new(self, left.and(right)) + end + end + + def in(other) + case other + when Arel::SelectManager + Arel::Nodes::In.new(self, other.ast) + when Range + if $VERBOSE + warn <<-eowarn +Passing a range to `#in` is deprecated. Call `#between`, instead. + eowarn + end + between(other) + when Enumerable + Nodes::In.new self, quoted_array(other) + else + Nodes::In.new self, quoted_node(other) + end + end + + def in_any(others) + grouping_any :in, others + end + + def in_all(others) + grouping_all :in, others + end + + def not_between(other) + if equals_quoted?(other.begin, -Float::INFINITY) + if equals_quoted?(other.end, Float::INFINITY) + self.in([]) + elsif other.exclude_end? + gteq(other.end) + else + gt(other.end) + end + elsif equals_quoted?(other.end, Float::INFINITY) + lt(other.begin) + else + left = lt(other.begin) + right = if other.exclude_end? + gteq(other.end) + else + gt(other.end) + end + left.or(right) + end + end + + def not_in(other) + case other + when Arel::SelectManager + Arel::Nodes::NotIn.new(self, other.ast) + when Range + if $VERBOSE + warn <<-eowarn +Passing a range to `#not_in` is deprecated. Call `#not_between`, instead. + eowarn + end + not_between(other) + when Enumerable + Nodes::NotIn.new self, quoted_array(other) + else + Nodes::NotIn.new self, quoted_node(other) + end + end + + def not_in_any(others) + grouping_any :not_in, others + end + + def not_in_all(others) + grouping_all :not_in, others + end + + def matches(other, escape = nil, case_sensitive = false) + Nodes::Matches.new self, quoted_node(other), escape, case_sensitive + end + + def matches_regexp(other, case_sensitive = true) + Nodes::Regexp.new self, quoted_node(other), case_sensitive + end + + def matches_any(others, escape = nil, case_sensitive = false) + grouping_any :matches, others, escape, case_sensitive + end + + def matches_all(others, escape = nil, case_sensitive = false) + grouping_all :matches, others, escape, case_sensitive + end + + def does_not_match(other, escape = nil, case_sensitive = false) + Nodes::DoesNotMatch.new self, quoted_node(other), escape, case_sensitive + end + + def does_not_match_regexp(other, case_sensitive = true) + Nodes::NotRegexp.new self, quoted_node(other), case_sensitive + end + + def does_not_match_any(others, escape = nil) + grouping_any :does_not_match, others, escape + end + + def does_not_match_all(others, escape = nil) + grouping_all :does_not_match, others, escape + end + + def gteq(right) + Nodes::GreaterThanOrEqual.new self, quoted_node(right) + end + + def gteq_any(others) + grouping_any :gteq, others + end + + def gteq_all(others) + grouping_all :gteq, others + end + + def gt(right) + Nodes::GreaterThan.new self, quoted_node(right) + end + + def gt_any(others) + grouping_any :gt, others + end + + def gt_all(others) + grouping_all :gt, others + end + + def lt(right) + Nodes::LessThan.new self, quoted_node(right) + end + + def lt_any(others) + grouping_any :lt, others + end + + def lt_all(others) + grouping_all :lt, others + end + + def lteq(right) + Nodes::LessThanOrEqual.new self, quoted_node(right) + end + + def lteq_any(others) + grouping_any :lteq, others + end + + def lteq_all(others) + grouping_all :lteq, others + end + + def when(right) + Nodes::Case.new(self).when quoted_node(right) + end + + def concat(other) + Nodes::Concat.new self, other + end + + private + + def grouping_any(method_id, others, *extras) + nodes = others.map { |expr| send(method_id, expr, *extras) } + Nodes::Grouping.new nodes.inject { |memo, node| + Nodes::Or.new(memo, node) + } + end + + def grouping_all(method_id, others, *extras) + nodes = others.map { |expr| send(method_id, expr, *extras) } + Nodes::Grouping.new Nodes::And.new(nodes) + end + + def quoted_node(other) + Nodes.build_quoted(other, self) + end + + def quoted_array(others) + others.map { |v| quoted_node(v) } + end + + def equals_quoted?(maybe_quoted, value) + if maybe_quoted.is_a?(Nodes::Quoted) + maybe_quoted.val == value + else + maybe_quoted == value + end + end + end +end diff --git a/activerecord/lib/arel/select_manager.rb b/activerecord/lib/arel/select_manager.rb new file mode 100644 index 0000000000..733176ad9e --- /dev/null +++ b/activerecord/lib/arel/select_manager.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class SelectManager < Arel::TreeManager + include Arel::Crud + + STRING_OR_SYMBOL_CLASS = [Symbol, String] + + def initialize(table = nil) + super() + @ast = Nodes::SelectStatement.new + @ctx = @ast.cores.last + from table + end + + def initialize_copy(other) + super + @ctx = @ast.cores.last + end + + def limit + @ast.limit && @ast.limit.expr + end + alias :taken :limit + + def constraints + @ctx.wheres + end + + def offset + @ast.offset && @ast.offset.expr + end + + def skip(amount) + if amount + @ast.offset = Nodes::Offset.new(amount) + else + @ast.offset = nil + end + self + end + alias :offset= :skip + + ### + # Produces an Arel::Nodes::Exists node + def exists + Arel::Nodes::Exists.new @ast + end + + def as(other) + create_table_alias grouping(@ast), Nodes::SqlLiteral.new(other) + end + + def lock(locking = Arel.sql("FOR UPDATE")) + case locking + when true + locking = Arel.sql("FOR UPDATE") + when Arel::Nodes::SqlLiteral + when String + locking = Arel.sql locking + end + + @ast.lock = Nodes::Lock.new(locking) + self + end + + def locked + @ast.lock + end + + def on(*exprs) + @ctx.source.right.last.right = Nodes::On.new(collapse(exprs)) + self + end + + def group(*columns) + columns.each do |column| + # FIXME: backwards compat + column = Nodes::SqlLiteral.new(column) if String === column + column = Nodes::SqlLiteral.new(column.to_s) if Symbol === column + + @ctx.groups.push Nodes::Group.new column + end + self + end + + def from(table) + table = Nodes::SqlLiteral.new(table) if String === table + + case table + when Nodes::Join + @ctx.source.right << table + else + @ctx.source.left = table + end + + self + end + + def froms + @ast.cores.map { |x| x.from }.compact + end + + def join(relation, klass = Nodes::InnerJoin) + return self unless relation + + case relation + when String, Nodes::SqlLiteral + raise EmptyJoinError if relation.empty? + klass = Nodes::StringJoin + end + + @ctx.source.right << create_join(relation, nil, klass) + self + end + + def outer_join(relation) + join(relation, Nodes::OuterJoin) + end + + def having(expr) + @ctx.havings << expr + self + end + + def window(name) + window = Nodes::NamedWindow.new(name) + @ctx.windows.push window + window + end + + def project(*projections) + # FIXME: converting these to SQLLiterals is probably not good, but + # rails tests require it. + @ctx.projections.concat projections.map { |x| + STRING_OR_SYMBOL_CLASS.include?(x.class) ? Nodes::SqlLiteral.new(x.to_s) : x + } + self + end + + def projections + @ctx.projections + end + + def projections=(projections) + @ctx.projections = projections + end + + def distinct(value = true) + if value + @ctx.set_quantifier = Arel::Nodes::Distinct.new + else + @ctx.set_quantifier = nil + end + self + end + + def distinct_on(value) + if value + @ctx.set_quantifier = Arel::Nodes::DistinctOn.new(value) + else + @ctx.set_quantifier = nil + end + self + end + + def order(*expr) + # FIXME: We SHOULD NOT be converting these to SqlLiteral automatically + @ast.orders.concat expr.map { |x| + STRING_OR_SYMBOL_CLASS.include?(x.class) ? Nodes::SqlLiteral.new(x.to_s) : x + } + self + end + + def orders + @ast.orders + end + + def where_sql(engine = Table.engine) + return if @ctx.wheres.empty? + + viz = Visitors::WhereSql.new(engine.connection.visitor, engine.connection) + Nodes::SqlLiteral.new viz.accept(@ctx, Collectors::SQLString.new).value + end + + def union(operation, other = nil) + if other + node_class = Nodes.const_get("Union#{operation.to_s.capitalize}") + else + other = operation + node_class = Nodes::Union + end + + node_class.new self.ast, other.ast + end + + def intersect(other) + Nodes::Intersect.new ast, other.ast + end + + def except(other) + Nodes::Except.new ast, other.ast + end + alias :minus :except + + def lateral(table_name = nil) + base = table_name.nil? ? ast : as(table_name) + Nodes::Lateral.new(base) + end + + def with(*subqueries) + if subqueries.first.is_a? Symbol + node_class = Nodes.const_get("With#{subqueries.shift.to_s.capitalize}") + else + node_class = Nodes::With + end + @ast.with = node_class.new(subqueries.flatten) + + self + end + + def take(limit) + if limit + @ast.limit = Nodes::Limit.new(limit) + @ctx.top = Nodes::Top.new(limit) + else + @ast.limit = nil + @ctx.top = nil + end + self + end + alias limit= take + + def join_sources + @ctx.source.right + end + + def source + @ctx.source + end + + class Row < Struct.new(:data) # :nodoc: + def id + data["id"] + end + + def method_missing(name, *args) + name = name.to_s + return data[name] if data.key?(name) + super + end + end + + private + def collapse(exprs) + exprs = exprs.compact + exprs.map! { |expr| + if String === expr + # FIXME: Don't do this automatically + Arel.sql(expr) + else + expr + end + } + + if exprs.length == 1 + exprs.first + else + create_and exprs + end + end + end +end diff --git a/activerecord/lib/arel/table.rb b/activerecord/lib/arel/table.rb new file mode 100644 index 0000000000..686fcdf962 --- /dev/null +++ b/activerecord/lib/arel/table.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class Table + include Arel::Crud + include Arel::FactoryMethods + + @engine = nil + class << self; attr_accessor :engine; end + + attr_accessor :name, :table_alias + + # TableAlias and Table both have a #table_name which is the name of the underlying table + alias :table_name :name + + def initialize(name, as: nil, type_caster: nil) + @name = name.to_s + @type_caster = type_caster + + # Sometime AR sends an :as parameter to table, to let the table know + # that it is an Alias. We may want to override new, and return a + # TableAlias node? + if as.to_s == @name + as = nil + end + @table_alias = as + end + + def alias(name = "#{self.name}_2") + Nodes::TableAlias.new(self, name) + end + + def from + SelectManager.new(self) + end + + def join(relation, klass = Nodes::InnerJoin) + return from unless relation + + case relation + when String, Nodes::SqlLiteral + raise EmptyJoinError if relation.empty? + klass = Nodes::StringJoin + end + + from.join(relation, klass) + end + + def outer_join(relation) + join(relation, Nodes::OuterJoin) + end + + def group(*columns) + from.group(*columns) + end + + def order(*expr) + from.order(*expr) + end + + def where(condition) + from.where condition + end + + def project(*things) + from.project(*things) + end + + def take(amount) + from.take amount + end + + def skip(amount) + from.skip amount + end + + def having(expr) + from.having expr + end + + def [](name) + ::Arel::Attribute.new self, name + end + + def hash + # Perf note: aliases and table alias is excluded from the hash + # aliases can have a loop back to this table breaking hashes in parent + # relations, for the vast majority of cases @name is unique to a query + @name.hash + end + + def eql?(other) + self.class == other.class && + self.name == other.name && + self.table_alias == other.table_alias + end + alias :== :eql? + + def type_cast_for_database(attribute_name, value) + type_caster.type_cast_for_database(attribute_name, value) + end + + def able_to_type_cast? + !type_caster.nil? + end + + protected + + attr_reader :type_caster + end +end diff --git a/activerecord/lib/arel/tree_manager.rb b/activerecord/lib/arel/tree_manager.rb new file mode 100644 index 0000000000..ed47b09a37 --- /dev/null +++ b/activerecord/lib/arel/tree_manager.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class TreeManager + include Arel::FactoryMethods + + attr_reader :ast + + def initialize + @ctx = nil + end + + def to_dot + collector = Arel::Collectors::PlainString.new + collector = Visitors::Dot.new.accept @ast, collector + collector.value + end + + def to_sql(engine = Table.engine) + collector = Arel::Collectors::SQLString.new + collector = engine.connection.visitor.accept @ast, collector + collector.value + end + + def initialize_copy(other) + super + @ast = @ast.clone + end + + def where(expr) + if Arel::TreeManager === expr + expr = expr.ast + end + @ctx.wheres << expr + self + end + end +end diff --git a/activerecord/lib/arel/update_manager.rb b/activerecord/lib/arel/update_manager.rb new file mode 100644 index 0000000000..fe444343ba --- /dev/null +++ b/activerecord/lib/arel/update_manager.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class UpdateManager < Arel::TreeManager + def initialize + super + @ast = Nodes::UpdateStatement.new + @ctx = @ast + end + + def take(limit) + @ast.limit = Nodes::Limit.new(Nodes.build_quoted(limit)) if limit + self + end + + def key=(key) + @ast.key = Nodes.build_quoted(key) + end + + def key + @ast.key + end + + def order(*expr) + @ast.orders = expr + self + end + + ### + # UPDATE +table+ + def table(table) + @ast.relation = table + self + end + + def wheres=(exprs) + @ast.wheres = exprs + end + + def where(expr) + @ast.wheres << expr + self + end + + def set(values) + if String === values + @ast.values = [values] + else + @ast.values = values.map { |column, value| + Nodes::Assignment.new( + Nodes::UnqualifiedColumn.new(column), + value + ) + } + end + self + end + end +end diff --git a/activerecord/lib/arel/visitors.rb b/activerecord/lib/arel/visitors.rb new file mode 100644 index 0000000000..e350f52e65 --- /dev/null +++ b/activerecord/lib/arel/visitors.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "arel/visitors/visitor" +require "arel/visitors/depth_first" +require "arel/visitors/to_sql" +require "arel/visitors/sqlite" +require "arel/visitors/postgresql" +require "arel/visitors/mysql" +require "arel/visitors/mssql" +require "arel/visitors/oracle" +require "arel/visitors/oracle12" +require "arel/visitors/where_sql" +require "arel/visitors/dot" +require "arel/visitors/ibm_db" +require "arel/visitors/informix" + +module Arel # :nodoc: all + module Visitors + end +end diff --git a/activerecord/lib/arel/visitors/depth_first.rb b/activerecord/lib/arel/visitors/depth_first.rb new file mode 100644 index 0000000000..bcf8f8f980 --- /dev/null +++ b/activerecord/lib/arel/visitors/depth_first.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class DepthFirst < Arel::Visitors::Visitor + def initialize(block = nil) + @block = block || Proc.new + super() + end + + private + + def visit(o) + super + @block.call o + end + + def unary(o) + visit o.expr + end + alias :visit_Arel_Nodes_Else :unary + alias :visit_Arel_Nodes_Group :unary + alias :visit_Arel_Nodes_Cube :unary + alias :visit_Arel_Nodes_RollUp :unary + alias :visit_Arel_Nodes_GroupingSet :unary + alias :visit_Arel_Nodes_GroupingElement :unary + alias :visit_Arel_Nodes_Grouping :unary + alias :visit_Arel_Nodes_Having :unary + alias :visit_Arel_Nodes_Lateral :unary + alias :visit_Arel_Nodes_Limit :unary + alias :visit_Arel_Nodes_Not :unary + alias :visit_Arel_Nodes_Offset :unary + alias :visit_Arel_Nodes_On :unary + alias :visit_Arel_Nodes_Ordering :unary + alias :visit_Arel_Nodes_Ascending :unary + alias :visit_Arel_Nodes_Descending :unary + alias :visit_Arel_Nodes_Top :unary + alias :visit_Arel_Nodes_UnqualifiedColumn :unary + + def function(o) + visit o.expressions + visit o.alias + visit o.distinct + end + alias :visit_Arel_Nodes_Avg :function + alias :visit_Arel_Nodes_Exists :function + alias :visit_Arel_Nodes_Max :function + alias :visit_Arel_Nodes_Min :function + alias :visit_Arel_Nodes_Sum :function + + def visit_Arel_Nodes_NamedFunction(o) + visit o.name + visit o.expressions + visit o.distinct + visit o.alias + end + + def visit_Arel_Nodes_Count(o) + visit o.expressions + visit o.alias + visit o.distinct + end + + def visit_Arel_Nodes_Case(o) + visit o.case + visit o.conditions + visit o.default + end + + def nary(o) + o.children.each { |child| visit child } + end + alias :visit_Arel_Nodes_And :nary + + def binary(o) + visit o.left + visit o.right + end + alias :visit_Arel_Nodes_As :binary + alias :visit_Arel_Nodes_Assignment :binary + alias :visit_Arel_Nodes_Between :binary + alias :visit_Arel_Nodes_Concat :binary + alias :visit_Arel_Nodes_DeleteStatement :binary + alias :visit_Arel_Nodes_DoesNotMatch :binary + alias :visit_Arel_Nodes_Equality :binary + alias :visit_Arel_Nodes_FullOuterJoin :binary + alias :visit_Arel_Nodes_GreaterThan :binary + alias :visit_Arel_Nodes_GreaterThanOrEqual :binary + alias :visit_Arel_Nodes_In :binary + alias :visit_Arel_Nodes_InfixOperation :binary + alias :visit_Arel_Nodes_JoinSource :binary + alias :visit_Arel_Nodes_InnerJoin :binary + alias :visit_Arel_Nodes_LessThan :binary + alias :visit_Arel_Nodes_LessThanOrEqual :binary + alias :visit_Arel_Nodes_Matches :binary + alias :visit_Arel_Nodes_NotEqual :binary + alias :visit_Arel_Nodes_NotIn :binary + alias :visit_Arel_Nodes_NotRegexp :binary + alias :visit_Arel_Nodes_Or :binary + alias :visit_Arel_Nodes_OuterJoin :binary + alias :visit_Arel_Nodes_Regexp :binary + alias :visit_Arel_Nodes_RightOuterJoin :binary + alias :visit_Arel_Nodes_TableAlias :binary + alias :visit_Arel_Nodes_Values :binary + alias :visit_Arel_Nodes_When :binary + + def visit_Arel_Nodes_StringJoin(o) + visit o.left + end + + def visit_Arel_Attribute(o) + visit o.relation + visit o.name + end + alias :visit_Arel_Attributes_Integer :visit_Arel_Attribute + alias :visit_Arel_Attributes_Float :visit_Arel_Attribute + alias :visit_Arel_Attributes_String :visit_Arel_Attribute + alias :visit_Arel_Attributes_Time :visit_Arel_Attribute + alias :visit_Arel_Attributes_Boolean :visit_Arel_Attribute + alias :visit_Arel_Attributes_Attribute :visit_Arel_Attribute + alias :visit_Arel_Attributes_Decimal :visit_Arel_Attribute + + def visit_Arel_Table(o) + visit o.name + end + + def terminal(o) + end + alias :visit_ActiveSupport_Multibyte_Chars :terminal + alias :visit_ActiveSupport_StringInquirer :terminal + alias :visit_Arel_Nodes_Lock :terminal + alias :visit_Arel_Nodes_Node :terminal + alias :visit_Arel_Nodes_SqlLiteral :terminal + alias :visit_Arel_Nodes_BindParam :terminal + alias :visit_Arel_Nodes_Window :terminal + alias :visit_Arel_Nodes_True :terminal + alias :visit_Arel_Nodes_False :terminal + alias :visit_BigDecimal :terminal + alias :visit_Bignum :terminal + alias :visit_Class :terminal + alias :visit_Date :terminal + alias :visit_DateTime :terminal + alias :visit_FalseClass :terminal + alias :visit_Fixnum :terminal + alias :visit_Float :terminal + alias :visit_Integer :terminal + alias :visit_NilClass :terminal + alias :visit_String :terminal + alias :visit_Symbol :terminal + alias :visit_Time :terminal + alias :visit_TrueClass :terminal + + def visit_Arel_Nodes_InsertStatement(o) + visit o.relation + visit o.columns + visit o.values + end + + def visit_Arel_Nodes_SelectCore(o) + visit o.projections + visit o.source + visit o.wheres + visit o.groups + visit o.windows + visit o.havings + end + + def visit_Arel_Nodes_SelectStatement(o) + visit o.cores + visit o.orders + visit o.limit + visit o.lock + visit o.offset + end + + def visit_Arel_Nodes_UpdateStatement(o) + visit o.relation + visit o.values + visit o.wheres + visit o.orders + visit o.limit + end + + def visit_Array(o) + o.each { |i| visit i } + end + alias :visit_Set :visit_Array + + def visit_Hash(o) + o.each { |k, v| visit(k); visit(v) } + end + + DISPATCH = dispatch_cache + + def get_dispatch_cache + DISPATCH + end + end + end +end diff --git a/activerecord/lib/arel/visitors/dot.rb b/activerecord/lib/arel/visitors/dot.rb new file mode 100644 index 0000000000..d352b81914 --- /dev/null +++ b/activerecord/lib/arel/visitors/dot.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class Dot < Arel::Visitors::Visitor + class Node # :nodoc: + attr_accessor :name, :id, :fields + + def initialize(name, id, fields = []) + @name = name + @id = id + @fields = fields + end + end + + class Edge < Struct.new :name, :from, :to # :nodoc: + end + + def initialize + super() + @nodes = [] + @edges = [] + @node_stack = [] + @edge_stack = [] + @seen = {} + end + + def accept(object, collector) + visit object + collector << to_dot + end + + private + + def visit_Arel_Nodes_Ordering(o) + visit_edge o, "expr" + end + + def visit_Arel_Nodes_TableAlias(o) + visit_edge o, "name" + visit_edge o, "relation" + end + + def visit_Arel_Nodes_Count(o) + visit_edge o, "expressions" + visit_edge o, "distinct" + end + + def visit_Arel_Nodes_Values(o) + visit_edge o, "expressions" + end + + def visit_Arel_Nodes_StringJoin(o) + visit_edge o, "left" + end + + def visit_Arel_Nodes_InnerJoin(o) + visit_edge o, "left" + visit_edge o, "right" + end + alias :visit_Arel_Nodes_FullOuterJoin :visit_Arel_Nodes_InnerJoin + alias :visit_Arel_Nodes_OuterJoin :visit_Arel_Nodes_InnerJoin + alias :visit_Arel_Nodes_RightOuterJoin :visit_Arel_Nodes_InnerJoin + + def visit_Arel_Nodes_DeleteStatement(o) + visit_edge o, "relation" + visit_edge o, "wheres" + end + + def unary(o) + visit_edge o, "expr" + end + alias :visit_Arel_Nodes_Group :unary + alias :visit_Arel_Nodes_Cube :unary + alias :visit_Arel_Nodes_RollUp :unary + alias :visit_Arel_Nodes_GroupingSet :unary + alias :visit_Arel_Nodes_GroupingElement :unary + alias :visit_Arel_Nodes_Grouping :unary + alias :visit_Arel_Nodes_Having :unary + alias :visit_Arel_Nodes_Limit :unary + alias :visit_Arel_Nodes_Not :unary + alias :visit_Arel_Nodes_Offset :unary + alias :visit_Arel_Nodes_On :unary + alias :visit_Arel_Nodes_Top :unary + alias :visit_Arel_Nodes_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_Preceding :unary + alias :visit_Arel_Nodes_Following :unary + alias :visit_Arel_Nodes_Rows :unary + alias :visit_Arel_Nodes_Range :unary + + def window(o) + visit_edge o, "partitions" + visit_edge o, "orders" + visit_edge o, "framing" + end + alias :visit_Arel_Nodes_Window :window + + def named_window(o) + visit_edge o, "partitions" + visit_edge o, "orders" + visit_edge o, "framing" + visit_edge o, "name" + end + alias :visit_Arel_Nodes_NamedWindow :named_window + + def function(o) + visit_edge o, "expressions" + visit_edge o, "distinct" + visit_edge o, "alias" + end + alias :visit_Arel_Nodes_Exists :function + alias :visit_Arel_Nodes_Min :function + alias :visit_Arel_Nodes_Max :function + alias :visit_Arel_Nodes_Avg :function + alias :visit_Arel_Nodes_Sum :function + + def extract(o) + visit_edge o, "expressions" + visit_edge o, "alias" + end + alias :visit_Arel_Nodes_Extract :extract + + def visit_Arel_Nodes_NamedFunction(o) + visit_edge o, "name" + visit_edge o, "expressions" + visit_edge o, "distinct" + visit_edge o, "alias" + end + + def visit_Arel_Nodes_InsertStatement(o) + visit_edge o, "relation" + visit_edge o, "columns" + visit_edge o, "values" + end + + def visit_Arel_Nodes_SelectCore(o) + visit_edge o, "source" + visit_edge o, "projections" + visit_edge o, "wheres" + visit_edge o, "windows" + end + + def visit_Arel_Nodes_SelectStatement(o) + visit_edge o, "cores" + visit_edge o, "limit" + visit_edge o, "orders" + visit_edge o, "offset" + end + + def visit_Arel_Nodes_UpdateStatement(o) + visit_edge o, "relation" + visit_edge o, "wheres" + visit_edge o, "values" + end + + def visit_Arel_Table(o) + visit_edge o, "name" + end + + def visit_Arel_Nodes_Casted(o) + visit_edge o, "val" + visit_edge o, "attribute" + end + + def visit_Arel_Attribute(o) + visit_edge o, "relation" + visit_edge o, "name" + end + alias :visit_Arel_Attributes_Integer :visit_Arel_Attribute + alias :visit_Arel_Attributes_Float :visit_Arel_Attribute + alias :visit_Arel_Attributes_String :visit_Arel_Attribute + alias :visit_Arel_Attributes_Time :visit_Arel_Attribute + alias :visit_Arel_Attributes_Boolean :visit_Arel_Attribute + alias :visit_Arel_Attributes_Attribute :visit_Arel_Attribute + + def nary(o) + o.children.each_with_index do |x, i| + edge(i) { visit x } + end + end + alias :visit_Arel_Nodes_And :nary + + def binary(o) + visit_edge o, "left" + visit_edge o, "right" + end + alias :visit_Arel_Nodes_As :binary + alias :visit_Arel_Nodes_Assignment :binary + alias :visit_Arel_Nodes_Between :binary + alias :visit_Arel_Nodes_Concat :binary + alias :visit_Arel_Nodes_DoesNotMatch :binary + alias :visit_Arel_Nodes_Equality :binary + alias :visit_Arel_Nodes_GreaterThan :binary + alias :visit_Arel_Nodes_GreaterThanOrEqual :binary + alias :visit_Arel_Nodes_In :binary + alias :visit_Arel_Nodes_JoinSource :binary + alias :visit_Arel_Nodes_LessThan :binary + alias :visit_Arel_Nodes_LessThanOrEqual :binary + alias :visit_Arel_Nodes_Matches :binary + alias :visit_Arel_Nodes_NotEqual :binary + alias :visit_Arel_Nodes_NotIn :binary + alias :visit_Arel_Nodes_Or :binary + alias :visit_Arel_Nodes_Over :binary + + def visit_String(o) + @node_stack.last.fields << o + end + alias :visit_Time :visit_String + alias :visit_Date :visit_String + alias :visit_DateTime :visit_String + alias :visit_NilClass :visit_String + alias :visit_TrueClass :visit_String + alias :visit_FalseClass :visit_String + alias :visit_Integer :visit_String + alias :visit_Fixnum :visit_String + alias :visit_BigDecimal :visit_String + alias :visit_Float :visit_String + alias :visit_Symbol :visit_String + alias :visit_Arel_Nodes_SqlLiteral :visit_String + + def visit_Arel_Nodes_BindParam(o); end + + def visit_Hash(o) + o.each_with_index do |pair, i| + edge("pair_#{i}") { visit pair } + end + end + + def visit_Array(o) + o.each_with_index do |x, i| + edge(i) { visit x } + end + end + alias :visit_Set :visit_Array + + def visit_edge(o, method) + edge(method) { visit o.send(method) } + end + + def visit(o) + if node = @seen[o.object_id] + @edge_stack.last.to = node + return + end + + node = Node.new(o.class.name, o.object_id) + @seen[node.id] = node + @nodes << node + with_node node do + super + end + end + + def edge(name) + edge = Edge.new(name, @node_stack.last) + @edge_stack.push edge + @edges << edge + yield + @edge_stack.pop + end + + def with_node(node) + if edge = @edge_stack.last + edge.to = node + end + + @node_stack.push node + yield + @node_stack.pop + end + + def quote(string) + string.to_s.gsub('"', '\"') + end + + def to_dot + "digraph \"Arel\" {\nnode [width=0.375,height=0.25,shape=record];\n" + + @nodes.map { |node| + label = "<f0>#{node.name}" + + node.fields.each_with_index do |field, i| + label += "|<f#{i + 1}>#{quote field}" + end + + "#{node.id} [label=\"#{label}\"];" + }.join("\n") + "\n" + @edges.map { |edge| + "#{edge.from.id} -> #{edge.to.id} [label=\"#{edge.name}\"];" + }.join("\n") + "\n}" + end + end + end +end diff --git a/activerecord/lib/arel/visitors/ibm_db.rb b/activerecord/lib/arel/visitors/ibm_db.rb new file mode 100644 index 0000000000..0a06aef60b --- /dev/null +++ b/activerecord/lib/arel/visitors/ibm_db.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class IBM_DB < Arel::Visitors::ToSql + private + + def visit_Arel_Nodes_Limit(o, collector) + collector << "FETCH FIRST " + collector = visit o.expr, collector + collector << " ROWS ONLY" + end + end + end +end diff --git a/activerecord/lib/arel/visitors/informix.rb b/activerecord/lib/arel/visitors/informix.rb new file mode 100644 index 0000000000..0a9713794e --- /dev/null +++ b/activerecord/lib/arel/visitors/informix.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class Informix < Arel::Visitors::ToSql + private + def visit_Arel_Nodes_SelectStatement(o, collector) + collector << "SELECT " + collector = maybe_visit o.offset, collector + collector = maybe_visit o.limit, collector + collector = o.cores.inject(collector) { |c, x| + visit_Arel_Nodes_SelectCore x, c + } + if o.orders.any? + collector << "ORDER BY " + collector = inject_join o.orders, collector, ", " + end + collector = maybe_visit o.lock, collector + end + def visit_Arel_Nodes_SelectCore(o, collector) + collector = inject_join o.projections, collector, ", " + if o.source && !o.source.empty? + collector << " FROM " + collector = visit o.source, collector + end + + if o.wheres.any? + collector << " WHERE " + collector = inject_join o.wheres, collector, " AND " + end + + if o.groups.any? + collector << "GROUP BY " + collector = inject_join o.groups, collector, ", " + end + + if o.havings.any? + collector << " HAVING " + collector = inject_join o.havings, collector, " AND " + end + collector + end + + def visit_Arel_Nodes_Offset(o, collector) + collector << "SKIP " + visit o.expr, collector + end + def visit_Arel_Nodes_Limit(o, collector) + collector << "FIRST " + visit o.expr, collector + collector << " " + end + end + end +end diff --git a/activerecord/lib/arel/visitors/mssql.rb b/activerecord/lib/arel/visitors/mssql.rb new file mode 100644 index 0000000000..9aedc51d15 --- /dev/null +++ b/activerecord/lib/arel/visitors/mssql.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class MSSQL < Arel::Visitors::ToSql + RowNumber = Struct.new :children + + def initialize(*) + @primary_keys = {} + super + end + + private + + # `top` wouldn't really work here. I.e. User.select("distinct first_name").limit(10) would generate + # "select top 10 distinct first_name from users", which is invalid query! it should be + # "select distinct top 10 first_name from users" + def visit_Arel_Nodes_Top(o) + "" + end + + def visit_Arel_Visitors_MSSQL_RowNumber(o, collector) + collector << "ROW_NUMBER() OVER (ORDER BY " + inject_join(o.children, collector, ", ") << ") as _row_num" + end + + def visit_Arel_Nodes_SelectStatement(o, collector) + if !o.limit && !o.offset + return super + end + + is_select_count = false + o.cores.each { |x| + core_order_by = row_num_literal determine_order_by(o.orders, x) + if select_count? x + x.projections = [core_order_by] + is_select_count = true + else + x.projections << core_order_by + end + } + + if is_select_count + # fixme count distinct wouldn't work with limit or offset + collector << "SELECT COUNT(1) as count_id FROM (" + end + + collector << "SELECT _t.* FROM (" + collector = o.cores.inject(collector) { |c, x| + visit_Arel_Nodes_SelectCore x, c + } + collector << ") as _t WHERE #{get_offset_limit_clause(o)}" + + if is_select_count + collector << ") AS subquery" + else + collector + end + end + + def get_offset_limit_clause(o) + first_row = o.offset ? o.offset.expr.to_i + 1 : 1 + last_row = o.limit ? o.limit.expr.to_i - 1 + first_row : nil + if last_row + " _row_num BETWEEN #{first_row} AND #{last_row}" + else + " _row_num >= #{first_row}" + end + end + + def visit_Arel_Nodes_DeleteStatement(o, collector) + collector << "DELETE " + if o.limit + collector << "TOP (" + visit o.limit.expr, collector + collector << ") " + end + collector << "FROM " + collector = visit o.relation, collector + if o.wheres.any? + collector << " WHERE " + inject_join o.wheres, collector, AND + else + collector + end + end + + def determine_order_by(orders, x) + if orders.any? + orders + elsif x.groups.any? + x.groups + else + pk = find_left_table_pk(x.froms) + pk ? [pk] : [] + end + end + + def row_num_literal(order_by) + RowNumber.new order_by + end + + def select_count?(x) + x.projections.length == 1 && Arel::Nodes::Count === x.projections.first + end + + # FIXME raise exception of there is no pk? + def find_left_table_pk(o) + if o.kind_of?(Arel::Nodes::Join) + find_left_table_pk(o.left) + elsif o.instance_of?(Arel::Table) + find_primary_key(o) + end + end + + def find_primary_key(o) + @primary_keys[o.name] ||= begin + primary_key_name = @connection.primary_key(o.name) + # some tables might be without primary key + primary_key_name && o[primary_key_name] + end + end + end + end +end diff --git a/activerecord/lib/arel/visitors/mysql.rb b/activerecord/lib/arel/visitors/mysql.rb new file mode 100644 index 0000000000..37bfb661f0 --- /dev/null +++ b/activerecord/lib/arel/visitors/mysql.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class MySQL < Arel::Visitors::ToSql + private + def visit_Arel_Nodes_Union(o, collector, suppress_parens = false) + unless suppress_parens + collector << "( " + end + + collector = case o.left + when Arel::Nodes::Union + visit_Arel_Nodes_Union o.left, collector, true + else + visit o.left, collector + end + + collector << " UNION " + + collector = case o.right + when Arel::Nodes::Union + visit_Arel_Nodes_Union o.right, collector, true + else + visit o.right, collector + end + + if suppress_parens + collector + else + collector << " )" + end + end + + def visit_Arel_Nodes_Bin(o, collector) + collector << "BINARY " + visit o.expr, collector + end + + ### + # :'( + # http://dev.mysql.com/doc/refman/5.0/en/select.html#id3482214 + def visit_Arel_Nodes_SelectStatement(o, collector) + if o.offset && !o.limit + o.limit = Arel::Nodes::Limit.new(18446744073709551615) + end + super + end + + def visit_Arel_Nodes_SelectCore(o, collector) + o.froms ||= Arel.sql("DUAL") + super + end + + def visit_Arel_Nodes_UpdateStatement(o, collector) + collector << "UPDATE " + collector = visit o.relation, collector + + unless o.values.empty? + collector << " SET " + collector = inject_join o.values, collector, ", " + end + + unless o.wheres.empty? + collector << " WHERE " + collector = inject_join o.wheres, collector, " AND " + end + + unless o.orders.empty? + collector << " ORDER BY " + collector = inject_join o.orders, collector, ", " + end + + maybe_visit o.limit, collector + end + + def visit_Arel_Nodes_Concat(o, collector) + collector << " CONCAT(" + visit o.left, collector + collector << ", " + visit o.right, collector + collector << ") " + collector + end + end + end +end diff --git a/activerecord/lib/arel/visitors/oracle.rb b/activerecord/lib/arel/visitors/oracle.rb new file mode 100644 index 0000000000..30a1529d46 --- /dev/null +++ b/activerecord/lib/arel/visitors/oracle.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class Oracle < Arel::Visitors::ToSql + private + + def visit_Arel_Nodes_SelectStatement(o, collector) + o = order_hacks(o) + + # if need to select first records without ORDER BY and GROUP BY and without DISTINCT + # then can use simple ROWNUM in WHERE clause + if o.limit && o.orders.empty? && o.cores.first.groups.empty? && !o.offset && o.cores.first.set_quantifier.class.to_s !~ /Distinct/ + o.cores.last.wheres.push Nodes::LessThanOrEqual.new( + Nodes::SqlLiteral.new("ROWNUM"), o.limit.expr + ) + return super + end + + if o.limit && o.offset + o = o.dup + limit = o.limit.expr + offset = o.offset + o.offset = nil + collector << " + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (" + + collector = super(o, collector) + + if offset.expr.is_a? Nodes::BindParam + collector << ") raw_sql_ WHERE rownum <= (" + collector = visit offset.expr, collector + collector << " + " + collector = visit limit, collector + collector << ") ) WHERE raw_rnum_ > " + collector = visit offset.expr, collector + return collector + else + collector << ") raw_sql_ + WHERE rownum <= #{offset.expr.to_i + limit} + ) + WHERE " + return visit(offset, collector) + end + end + + if o.limit + o = o.dup + limit = o.limit.expr + collector << "SELECT * FROM (" + collector = super(o, collector) + collector << ") WHERE ROWNUM <= " + return visit limit, collector + end + + if o.offset + o = o.dup + offset = o.offset + o.offset = nil + collector << "SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (" + collector = super(o, collector) + collector << ") raw_sql_ + ) + WHERE " + return visit offset, collector + end + + super + end + + def visit_Arel_Nodes_Limit(o, collector) + collector + end + + def visit_Arel_Nodes_Offset(o, collector) + collector << "raw_rnum_ > " + visit o.expr, collector + end + + def visit_Arel_Nodes_Except(o, collector) + collector << "( " + collector = infix_value o, collector, " MINUS " + collector << " )" + end + + def visit_Arel_Nodes_UpdateStatement(o, collector) + # Oracle does not allow ORDER BY/LIMIT in UPDATEs. + if o.orders.any? && o.limit.nil? + # However, there is no harm in silently eating the ORDER BY clause if no LIMIT has been provided, + # otherwise let the user deal with the error + o = o.dup + o.orders = [] + end + + super + end + + ### + # Hacks for the order clauses specific to Oracle + def order_hacks(o) + return o if o.orders.empty? + return o unless o.cores.any? do |core| + core.projections.any? do |projection| + /FIRST_VALUE/ === projection + end + end + # Previous version with join and split broke ORDER BY clause + # if it contained functions with several arguments (separated by ','). + # + # orders = o.orders.map { |x| visit x }.join(', ').split(',') + orders = o.orders.map do |x| + string = visit(x, Arel::Collectors::SQLString.new).value + if string.include?(",") + split_order_string(string) + else + string + end + end.flatten + o.orders = [] + orders.each_with_index do |order, i| + o.orders << + Nodes::SqlLiteral.new("alias_#{i}__#{' DESC' if /\bdesc$/i === order}") + end + o + end + + # Split string by commas but count opening and closing brackets + # and ignore commas inside brackets. + def split_order_string(string) + array = [] + i = 0 + string.split(",").each do |part| + if array[i] + array[i] << "," << part + else + # to ensure that array[i] will be String and not Arel::Nodes::SqlLiteral + array[i] = part.to_s + end + i += 1 if array[i].count("(") == array[i].count(")") + end + array + end + + def visit_Arel_Nodes_BindParam(o, collector) + collector.add_bind(o.value) { |i| ":a#{i}" } + end + end + end +end diff --git a/activerecord/lib/arel/visitors/oracle12.rb b/activerecord/lib/arel/visitors/oracle12.rb new file mode 100644 index 0000000000..7061f06087 --- /dev/null +++ b/activerecord/lib/arel/visitors/oracle12.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class Oracle12 < Arel::Visitors::ToSql + private + + 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 + end + super + end + + def visit_Arel_Nodes_SelectOptions(o, collector) + collector = maybe_visit o.offset, collector + collector = maybe_visit o.limit, collector + collector = maybe_visit o.lock, collector + end + + def visit_Arel_Nodes_Limit(o, collector) + collector << "FETCH FIRST " + collector = visit o.expr, collector + collector << " ROWS ONLY" + end + + def visit_Arel_Nodes_Offset(o, collector) + collector << "OFFSET " + visit o.expr, collector + collector << " ROWS" + end + + def visit_Arel_Nodes_Except(o, collector) + collector << "( " + collector = infix_value o, collector, " MINUS " + collector << " )" + end + + def visit_Arel_Nodes_UpdateStatement(o, collector) + # Oracle does not allow ORDER BY/LIMIT in UPDATEs. + if o.orders.any? && o.limit.nil? + # However, there is no harm in silently eating the ORDER BY clause if no LIMIT has been provided, + # otherwise let the user deal with the error + o = o.dup + o.orders = [] + end + + super + end + + def visit_Arel_Nodes_BindParam(o, collector) + collector.add_bind(o.value) { |i| ":a#{i}" } + end + end + end +end diff --git a/activerecord/lib/arel/visitors/postgresql.rb b/activerecord/lib/arel/visitors/postgresql.rb new file mode 100644 index 0000000000..c5110fa89c --- /dev/null +++ b/activerecord/lib/arel/visitors/postgresql.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class PostgreSQL < Arel::Visitors::ToSql + CUBE = "CUBE" + ROLLUP = "ROLLUP" + GROUPING_SETS = "GROUPING SETS" + LATERAL = "LATERAL" + + private + + def visit_Arel_Nodes_Matches(o, collector) + op = o.case_sensitive ? " LIKE " : " ILIKE " + collector = infix_value o, collector, op + if o.escape + collector << " ESCAPE " + visit o.escape, collector + else + collector + end + end + + def visit_Arel_Nodes_DoesNotMatch(o, collector) + op = o.case_sensitive ? " NOT LIKE " : " NOT ILIKE " + collector = infix_value o, collector, op + if o.escape + collector << " ESCAPE " + visit o.escape, collector + else + collector + end + end + + def visit_Arel_Nodes_Regexp(o, collector) + op = o.case_sensitive ? " ~ " : " ~* " + infix_value o, collector, op + end + + def visit_Arel_Nodes_NotRegexp(o, collector) + op = o.case_sensitive ? " !~ " : " !~* " + infix_value o, collector, op + end + + def visit_Arel_Nodes_DistinctOn(o, collector) + collector << "DISTINCT ON ( " + visit(o.expr, collector) << " )" + end + + def visit_Arel_Nodes_BindParam(o, collector) + collector.add_bind(o.value) { |i| "$#{i}" } + end + + def visit_Arel_Nodes_GroupingElement(o, collector) + collector << "( " + visit(o.expr, collector) << " )" + end + + def visit_Arel_Nodes_Cube(o, collector) + collector << CUBE + grouping_array_or_grouping_element o, collector + end + + def visit_Arel_Nodes_RollUp(o, collector) + collector << ROLLUP + grouping_array_or_grouping_element o, collector + end + + def visit_Arel_Nodes_GroupingSet(o, collector) + collector << GROUPING_SETS + grouping_array_or_grouping_element o, collector + end + + def visit_Arel_Nodes_Lateral(o, collector) + collector << LATERAL + collector << SPACE + grouping_parentheses o, collector + end + + # Used by Lateral visitor to enclose select queries in parentheses + def grouping_parentheses(o, collector) + if o.expr.is_a? Nodes::SelectStatement + collector << "(" + visit o.expr, collector + collector << ")" + else + visit o.expr, collector + end + end + + # Utilized by GroupingSet, Cube & RollUp visitors to + # handle grouping aggregation semantics + def grouping_array_or_grouping_element(o, collector) + if o.expr.is_a? Array + collector << "( " + visit o.expr, collector + collector << " )" + else + visit o.expr, collector + end + end + end + end +end diff --git a/activerecord/lib/arel/visitors/sqlite.rb b/activerecord/lib/arel/visitors/sqlite.rb new file mode 100644 index 0000000000..cb1d2424ad --- /dev/null +++ b/activerecord/lib/arel/visitors/sqlite.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class SQLite < Arel::Visitors::ToSql + private + + # Locks are not supported in SQLite + def visit_Arel_Nodes_Lock(o, collector) + collector + end + + def visit_Arel_Nodes_SelectStatement(o, collector) + o.limit = Arel::Nodes::Limit.new(-1) if o.offset && !o.limit + super + end + + def visit_Arel_Nodes_True(o, collector) + collector << "1" + end + + def visit_Arel_Nodes_False(o, collector) + collector << "0" + end + end + end +end diff --git a/activerecord/lib/arel/visitors/to_sql.rb b/activerecord/lib/arel/visitors/to_sql.rb new file mode 100644 index 0000000000..0682c066fb --- /dev/null +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -0,0 +1,847 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class UnsupportedVisitError < StandardError + def initialize(object) + super "Unsupported argument type: #{object.class.name}. Construct an Arel node instead." + end + end + + class ToSql < Arel::Visitors::Visitor + ## + # This is some roflscale crazy stuff. I'm roflscaling this because + # building SQL queries is a hotspot. I will explain the roflscale so that + # others will not rm this code. + # + # In YARV, string literals in a method body will get duped when the byte + # code is executed. Let's take a look: + # + # > puts RubyVM::InstructionSequence.new('def foo; "bar"; end').disasm + # + # == disasm: <RubyVM::InstructionSequence:foo@<compiled>>===== + # 0000 trace 8 + # 0002 trace 1 + # 0004 putstring "bar" + # 0006 trace 16 + # 0008 leave + # + # The `putstring` bytecode will dup the string and push it on the stack. + # In many cases in our SQL visitor, that string is never mutated, so there + # is no need to dup the literal. + # + # If we change to a constant lookup, the string will not be duped, and we + # can reduce the objects in our system: + # + # > puts RubyVM::InstructionSequence.new('BAR = "bar"; def foo; BAR; end').disasm + # + # == disasm: <RubyVM::InstructionSequence:foo@<compiled>>======== + # 0000 trace 8 + # 0002 trace 1 + # 0004 getinlinecache 11, <ic:0> + # 0007 getconstant :BAR + # 0009 setinlinecache <ic:0> + # 0011 trace 16 + # 0013 leave + # + # `getconstant` should be a hash lookup, and no object is duped when the + # value of the constant is pushed on the stack. Hence the crazy + # constants below. + # + # `matches` and `doesNotMatch` operate case-insensitively via Visitor subclasses + # specialized for specific databases when necessary. + # + + WHERE = " WHERE " # :nodoc: + SPACE = " " # :nodoc: + COMMA = ", " # :nodoc: + GROUP_BY = " GROUP BY " # :nodoc: + ORDER_BY = " ORDER BY " # :nodoc: + WINDOW = " WINDOW " # :nodoc: + AND = " AND " # :nodoc: + + DISTINCT = "DISTINCT" # :nodoc: + + def initialize(connection) + super() + @connection = connection + end + + def compile(node, collector = Arel::Collectors::SQLString.new, &block) + accept(node, collector, &block).value + end + + private + + def visit_Arel_Nodes_DeleteStatement(o, collector) + collector << "DELETE FROM " + collector = visit o.relation, collector + if o.wheres.any? + collector << WHERE + collector = inject_join o.wheres, collector, AND + end + + maybe_visit o.limit, collector + end + + # FIXME: we should probably have a 2-pass visitor for this + def build_subselect(key, o) + stmt = Nodes::SelectStatement.new + core = stmt.cores.first + core.froms = o.relation + core.wheres = o.wheres + core.projections = [key] + stmt.limit = o.limit + stmt.orders = o.orders + stmt + end + + def visit_Arel_Nodes_UpdateStatement(o, collector) + if o.orders.empty? && o.limit.nil? + wheres = o.wheres + else + wheres = [Nodes::In.new(o.key, [build_subselect(o.key, o)])] + end + + collector << "UPDATE " + collector = visit o.relation, collector + unless o.values.empty? + collector << " SET " + collector = inject_join o.values, collector, ", " + end + + unless wheres.empty? + collector << " WHERE " + collector = inject_join wheres, collector, " AND " + end + + collector + end + + def visit_Arel_Nodes_InsertStatement(o, collector) + collector << "INSERT INTO " + collector = visit o.relation, collector + if o.columns.any? + collector << " (#{o.columns.map { |x| + quote_column_name x.name + }.join ', '})" + end + + if o.values + maybe_visit o.values, collector + elsif o.select + maybe_visit o.select, collector + else + collector + end + end + + def visit_Arel_Nodes_Exists(o, collector) + collector << "EXISTS (" + collector = visit(o.expressions, collector) << ")" + if o.alias + collector << " AS " + visit o.alias, collector + else + collector + end + end + + def visit_Arel_Nodes_Casted(o, collector) + collector << quoted(o.val, o.attribute).to_s + end + + def visit_Arel_Nodes_Quoted(o, collector) + collector << quoted(o.expr, nil).to_s + end + + def visit_Arel_Nodes_True(o, collector) + collector << "TRUE" + end + + def visit_Arel_Nodes_False(o, collector) + collector << "FALSE" + end + + def visit_Arel_Nodes_ValuesList(o, collector) + collector << "VALUES " + + len = o.rows.length - 1 + o.rows.each_with_index { |row, i| + collector << "(" + row_len = row.length - 1 + row.each_with_index do |value, k| + case value + when Nodes::SqlLiteral, Nodes::BindParam + collector = visit(value, collector) + else + collector << quote(value) + end + collector << COMMA unless k == row_len + end + collector << ")" + collector << COMMA unless i == len + } + collector + end + + def visit_Arel_Nodes_Values(o, collector) + collector << "VALUES (" + + len = o.expressions.length - 1 + o.expressions.each_with_index { |value, i| + case value + when Nodes::SqlLiteral, Nodes::BindParam + collector = visit value, collector + else + collector << quote(value).to_s + end + unless i == len + collector << COMMA + end + } + + collector << ")" + end + + def visit_Arel_Nodes_SelectStatement(o, collector) + if o.with + collector = visit o.with, collector + collector << SPACE + end + + collector = o.cores.inject(collector) { |c, x| + visit_Arel_Nodes_SelectCore(x, c) + } + + unless o.orders.empty? + collector << ORDER_BY + len = o.orders.length - 1 + o.orders.each_with_index { |x, i| + collector = visit(x, collector) + collector << COMMA unless len == i + } + end + + visit_Arel_Nodes_SelectOptions(o, collector) + + collector + end + + def visit_Arel_Nodes_SelectOptions(o, collector) + collector = maybe_visit o.limit, collector + collector = maybe_visit o.offset, collector + collector = maybe_visit o.lock, collector + end + + def visit_Arel_Nodes_SelectCore(o, collector) + collector << "SELECT" + + collector = maybe_visit o.top, collector + + collector = maybe_visit o.set_quantifier, collector + + collect_nodes_for o.projections, collector, SPACE + + if o.source && !o.source.empty? + collector << " FROM " + collector = visit o.source, collector + end + + collect_nodes_for o.wheres, collector, WHERE, AND + collect_nodes_for o.groups, collector, GROUP_BY + unless o.havings.empty? + collector << " HAVING " + inject_join o.havings, collector, AND + end + collect_nodes_for o.windows, collector, WINDOW + + collector + end + + def collect_nodes_for(nodes, collector, spacer, connector = COMMA) + unless nodes.empty? + collector << spacer + len = nodes.length - 1 + nodes.each_with_index do |x, i| + collector = visit(x, collector) + collector << connector unless len == i + end + end + end + + def visit_Arel_Nodes_Bin(o, collector) + visit o.expr, collector + end + + def visit_Arel_Nodes_Distinct(o, collector) + collector << DISTINCT + end + + def visit_Arel_Nodes_DistinctOn(o, collector) + raise NotImplementedError, "DISTINCT ON not implemented for this db" + end + + def visit_Arel_Nodes_With(o, collector) + collector << "WITH " + inject_join o.children, collector, COMMA + end + + def visit_Arel_Nodes_WithRecursive(o, collector) + collector << "WITH RECURSIVE " + inject_join o.children, collector, COMMA + end + + def visit_Arel_Nodes_Union(o, collector) + collector << "( " + infix_value(o, collector, " UNION ") << " )" + end + + def visit_Arel_Nodes_UnionAll(o, collector) + collector << "( " + infix_value(o, collector, " UNION ALL ") << " )" + end + + def visit_Arel_Nodes_Intersect(o, collector) + collector << "( " + infix_value(o, collector, " INTERSECT ") << " )" + end + + def visit_Arel_Nodes_Except(o, collector) + collector << "( " + infix_value(o, collector, " EXCEPT ") << " )" + end + + def visit_Arel_Nodes_NamedWindow(o, collector) + collector << quote_column_name(o.name) + collector << " AS " + visit_Arel_Nodes_Window o, collector + end + + def visit_Arel_Nodes_Window(o, collector) + collector << "(" + + if o.partitions.any? + collector << "PARTITION BY " + collector = inject_join o.partitions, collector, ", " + end + + if o.orders.any? + collector << SPACE if o.partitions.any? + collector << "ORDER BY " + collector = inject_join o.orders, collector, ", " + end + + if o.framing + collector << SPACE if o.partitions.any? || o.orders.any? + collector = visit o.framing, collector + end + + collector << ")" + end + + def visit_Arel_Nodes_Rows(o, collector) + if o.expr + collector << "ROWS " + visit o.expr, collector + else + collector << "ROWS" + end + end + + def visit_Arel_Nodes_Range(o, collector) + if o.expr + collector << "RANGE " + visit o.expr, collector + else + collector << "RANGE" + end + end + + def visit_Arel_Nodes_Preceding(o, collector) + collector = if o.expr + visit o.expr, collector + else + collector << "UNBOUNDED" + end + + collector << " PRECEDING" + end + + def visit_Arel_Nodes_Following(o, collector) + collector = if o.expr + visit o.expr, collector + else + collector << "UNBOUNDED" + end + + collector << " FOLLOWING" + end + + def visit_Arel_Nodes_CurrentRow(o, collector) + collector << "CURRENT ROW" + end + + def visit_Arel_Nodes_Over(o, collector) + case o.right + when nil + visit(o.left, collector) << " OVER ()" + when Arel::Nodes::SqlLiteral + infix_value o, collector, " OVER " + when String, Symbol + visit(o.left, collector) << " OVER #{quote_column_name o.right.to_s}" + else + infix_value o, collector, " OVER " + end + end + + def visit_Arel_Nodes_Offset(o, collector) + collector << "OFFSET " + visit o.expr, collector + end + + def visit_Arel_Nodes_Limit(o, collector) + collector << "LIMIT " + visit o.expr, collector + end + + # FIXME: this does nothing on most databases, but does on MSSQL + def visit_Arel_Nodes_Top(o, collector) + collector + end + + def visit_Arel_Nodes_Lock(o, collector) + visit o.expr, collector + end + + def visit_Arel_Nodes_Grouping(o, collector) + if o.expr.is_a? Nodes::Grouping + visit(o.expr, collector) + else + collector << "(" + visit(o.expr, collector) << ")" + end + end + + def visit_Arel_SelectManager(o, collector) + collector << "(" + visit(o.ast, collector) << ")" + end + + def visit_Arel_Nodes_Ascending(o, collector) + visit(o.expr, collector) << " ASC" + end + + def visit_Arel_Nodes_Descending(o, collector) + visit(o.expr, collector) << " DESC" + end + + def visit_Arel_Nodes_Group(o, collector) + visit o.expr, collector + end + + def visit_Arel_Nodes_NamedFunction(o, collector) + collector << o.name + collector << "(" + collector << "DISTINCT " if o.distinct + collector = inject_join(o.expressions, collector, ", ") << ")" + if o.alias + collector << " AS " + visit o.alias, collector + else + collector + end + end + + def visit_Arel_Nodes_Extract(o, collector) + collector << "EXTRACT(#{o.field.to_s.upcase} FROM " + visit(o.expr, collector) << ")" + end + + def visit_Arel_Nodes_Count(o, collector) + aggregate "COUNT", o, collector + end + + def visit_Arel_Nodes_Sum(o, collector) + aggregate "SUM", o, collector + end + + def visit_Arel_Nodes_Max(o, collector) + aggregate "MAX", o, collector + end + + def visit_Arel_Nodes_Min(o, collector) + aggregate "MIN", o, collector + end + + def visit_Arel_Nodes_Avg(o, collector) + aggregate "AVG", o, collector + end + + def visit_Arel_Nodes_TableAlias(o, collector) + collector = visit o.relation, collector + collector << " " + collector << quote_table_name(o.name) + end + + def visit_Arel_Nodes_Between(o, collector) + collector = visit o.left, collector + collector << " BETWEEN " + visit o.right, collector + end + + def visit_Arel_Nodes_GreaterThanOrEqual(o, collector) + collector = visit o.left, collector + collector << " >= " + visit o.right, collector + end + + def visit_Arel_Nodes_GreaterThan(o, collector) + collector = visit o.left, collector + collector << " > " + visit o.right, collector + end + + def visit_Arel_Nodes_LessThanOrEqual(o, collector) + collector = visit o.left, collector + collector << " <= " + visit o.right, collector + end + + def visit_Arel_Nodes_LessThan(o, collector) + collector = visit o.left, collector + collector << " < " + visit o.right, collector + end + + def visit_Arel_Nodes_Matches(o, collector) + collector = visit o.left, collector + collector << " LIKE " + collector = visit o.right, collector + if o.escape + collector << " ESCAPE " + visit o.escape, collector + else + collector + end + end + + def visit_Arel_Nodes_DoesNotMatch(o, collector) + collector = visit o.left, collector + collector << " NOT LIKE " + collector = visit o.right, collector + if o.escape + collector << " ESCAPE " + visit o.escape, collector + else + collector + end + end + + def visit_Arel_Nodes_JoinSource(o, collector) + if o.left + collector = visit o.left, collector + end + if o.right.any? + collector << SPACE if o.left + collector = inject_join o.right, collector, SPACE + end + collector + end + + def visit_Arel_Nodes_Regexp(o, collector) + raise NotImplementedError, "~ not implemented for this db" + end + + def visit_Arel_Nodes_NotRegexp(o, collector) + raise NotImplementedError, "!~ not implemented for this db" + end + + def visit_Arel_Nodes_StringJoin(o, collector) + visit o.left, collector + end + + def visit_Arel_Nodes_FullOuterJoin(o, collector) + collector << "FULL OUTER JOIN " + collector = visit o.left, collector + collector << SPACE + visit o.right, collector + end + + def visit_Arel_Nodes_OuterJoin(o, collector) + collector << "LEFT OUTER JOIN " + collector = visit o.left, collector + collector << " " + visit o.right, collector + end + + def visit_Arel_Nodes_RightOuterJoin(o, collector) + collector << "RIGHT OUTER JOIN " + collector = visit o.left, collector + collector << SPACE + visit o.right, collector + end + + def visit_Arel_Nodes_InnerJoin(o, collector) + collector << "INNER JOIN " + collector = visit o.left, collector + if o.right + collector << SPACE + visit(o.right, collector) + else + collector + end + end + + def visit_Arel_Nodes_On(o, collector) + collector << "ON " + visit o.expr, collector + end + + def visit_Arel_Nodes_Not(o, collector) + collector << "NOT (" + visit(o.expr, collector) << ")" + end + + def visit_Arel_Table(o, collector) + if o.table_alias + collector << "#{quote_table_name o.name} #{quote_table_name o.table_alias}" + else + collector << quote_table_name(o.name) + end + end + + def visit_Arel_Nodes_In(o, collector) + if Array === o.right && o.right.empty? + collector << "1=0" + else + collector = visit o.left, collector + collector << " IN (" + visit(o.right, collector) << ")" + end + end + + def visit_Arel_Nodes_NotIn(o, collector) + if Array === o.right && o.right.empty? + collector << "1=1" + else + collector = visit o.left, collector + collector << " NOT IN (" + collector = visit o.right, collector + collector << ")" + end + end + + def visit_Arel_Nodes_And(o, collector) + inject_join o.children, collector, " AND " + end + + def visit_Arel_Nodes_Or(o, collector) + collector = visit o.left, collector + collector << " OR " + visit o.right, collector + end + + def visit_Arel_Nodes_Assignment(o, collector) + case o.right + when Arel::Nodes::UnqualifiedColumn, Arel::Attributes::Attribute, Arel::Nodes::BindParam + collector = visit o.left, collector + collector << " = " + visit o.right, collector + else + collector = visit o.left, collector + collector << " = " + collector << quote(o.right).to_s + end + end + + def visit_Arel_Nodes_Equality(o, collector) + right = o.right + + collector = visit o.left, collector + + if right.nil? + collector << " IS NULL" + else + collector << " = " + visit right, collector + end + end + + def visit_Arel_Nodes_NotEqual(o, collector) + right = o.right + + collector = visit o.left, collector + + if right.nil? + collector << " IS NOT NULL" + else + collector << " != " + visit right, collector + end + end + + def visit_Arel_Nodes_As(o, collector) + collector = visit o.left, collector + collector << " AS " + visit o.right, collector + end + + def visit_Arel_Nodes_Case(o, collector) + collector << "CASE " + if o.case + visit o.case, collector + collector << " " + end + o.conditions.each do |condition| + visit condition, collector + collector << " " + end + if o.default + visit o.default, collector + collector << " " + end + collector << "END" + end + + def visit_Arel_Nodes_When(o, collector) + collector << "WHEN " + visit o.left, collector + collector << " THEN " + visit o.right, collector + end + + def visit_Arel_Nodes_Else(o, collector) + collector << "ELSE " + visit o.expr, collector + end + + def visit_Arel_Nodes_UnqualifiedColumn(o, collector) + collector << "#{quote_column_name o.name}" + collector + end + + def visit_Arel_Attributes_Attribute(o, collector) + join_name = o.relation.table_alias || o.relation.name + collector << "#{quote_table_name join_name}.#{quote_column_name o.name}" + end + alias :visit_Arel_Attributes_Integer :visit_Arel_Attributes_Attribute + alias :visit_Arel_Attributes_Float :visit_Arel_Attributes_Attribute + alias :visit_Arel_Attributes_Decimal :visit_Arel_Attributes_Attribute + alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute + alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute + alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute + + def literal(o, collector); collector << o.to_s; end + + def visit_Arel_Nodes_BindParam(o, collector) + collector.add_bind(o.value) { "?" } + end + + alias :visit_Arel_Nodes_SqlLiteral :literal + alias :visit_Bignum :literal + alias :visit_Fixnum :literal + alias :visit_Integer :literal + + def quoted(o, a) + if a && a.able_to_type_cast? + quote(a.type_cast_for_database(o)) + else + quote(o) + end + end + + def unsupported(o, collector) + raise UnsupportedVisitError.new(o) + end + + alias :visit_ActiveSupport_Multibyte_Chars :unsupported + alias :visit_ActiveSupport_StringInquirer :unsupported + alias :visit_BigDecimal :unsupported + alias :visit_Class :unsupported + alias :visit_Date :unsupported + alias :visit_DateTime :unsupported + alias :visit_FalseClass :unsupported + alias :visit_Float :unsupported + alias :visit_Hash :unsupported + alias :visit_NilClass :unsupported + alias :visit_String :unsupported + alias :visit_Symbol :unsupported + alias :visit_Time :unsupported + alias :visit_TrueClass :unsupported + + def visit_Arel_Nodes_InfixOperation(o, collector) + collector = visit o.left, collector + collector << " #{o.operator} " + visit o.right, collector + end + + alias :visit_Arel_Nodes_Addition :visit_Arel_Nodes_InfixOperation + alias :visit_Arel_Nodes_Subtraction :visit_Arel_Nodes_InfixOperation + alias :visit_Arel_Nodes_Multiplication :visit_Arel_Nodes_InfixOperation + alias :visit_Arel_Nodes_Division :visit_Arel_Nodes_InfixOperation + + def visit_Arel_Nodes_UnaryOperation(o, collector) + collector << " #{o.operator} " + visit o.expr, collector + end + + def visit_Array(o, collector) + inject_join o, collector, ", " + end + alias :visit_Set :visit_Array + + def quote(value) + return value if Arel::Nodes::SqlLiteral === value + @connection.quote value + end + + def quote_table_name(name) + return name if Arel::Nodes::SqlLiteral === name + @connection.quote_table_name(name) + end + + def quote_column_name(name) + return name if Arel::Nodes::SqlLiteral === name + @connection.quote_column_name(name) + end + + def maybe_visit(thing, collector) + return collector unless thing + collector << " " + visit thing, collector + end + + def inject_join(list, collector, join_str) + len = list.length - 1 + list.each_with_index.inject(collector) { |c, (x, i)| + if i == len + visit x, c + else + visit(x, c) << join_str + end + } + end + + def infix_value(o, collector, value) + collector = visit o.left, collector + collector << value + visit o.right, collector + end + + def aggregate(name, o, collector) + collector << "#{name}(" + if o.distinct + collector << "DISTINCT " + end + collector = inject_join(o.expressions, collector, ", ") << ")" + if o.alias + collector << " AS " + visit o.alias, collector + else + collector + end + end + end + end +end diff --git a/activerecord/lib/arel/visitors/visitor.rb b/activerecord/lib/arel/visitors/visitor.rb new file mode 100644 index 0000000000..1c17184e86 --- /dev/null +++ b/activerecord/lib/arel/visitors/visitor.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class Visitor + def initialize + @dispatch = get_dispatch_cache + end + + def accept(object, *args) + visit object, *args + end + + private + + attr_reader :dispatch + + def self.dispatch_cache + Hash.new do |hash, klass| + hash[klass] = "visit_#{(klass.name || '').gsub('::', '_')}" + end + end + + def get_dispatch_cache + self.class.dispatch_cache + end + + def visit(object, *args) + dispatch_method = dispatch[object.class] + send dispatch_method, object, *args + rescue NoMethodError => e + raise e if respond_to?(dispatch_method, true) + superklass = object.class.ancestors.find { |klass| + respond_to?(dispatch[klass], true) + } + raise(TypeError, "Cannot visit #{object.class}") unless superklass + dispatch[object.class] = dispatch[superklass] + retry + end + end + end +end diff --git a/activerecord/lib/arel/visitors/where_sql.rb b/activerecord/lib/arel/visitors/where_sql.rb new file mode 100644 index 0000000000..c6caf5e7c9 --- /dev/null +++ b/activerecord/lib/arel/visitors/where_sql.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class WhereSql < Arel::Visitors::ToSql + def initialize(inner_visitor, *args, &block) + @inner_visitor = inner_visitor + super(*args, &block) + end + + private + + def visit_Arel_Nodes_SelectCore(o, collector) + collector << "WHERE " + wheres = o.wheres.map do |where| + Nodes::SqlLiteral.new(@inner_visitor.accept(where, collector.class.new).value) + end + + inject_join wheres, collector, " AND " + end + end + end +end diff --git a/activerecord/lib/arel/window_predications.rb b/activerecord/lib/arel/window_predications.rb new file mode 100644 index 0000000000..3a8ee41f8a --- /dev/null +++ b/activerecord/lib/arel/window_predications.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module WindowPredications + def over(expr = nil) + Nodes::Over.new(self, expr) + end + end +end diff --git a/activerecord/lib/rails/generators/active_record.rb b/activerecord/lib/rails/generators/active_record.rb index 68fca44e3b..a7e5e373a7 100644 --- a/activerecord/lib/rails/generators/active_record.rb +++ b/activerecord/lib/rails/generators/active_record.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails/generators/named_base" require "rails/generators/active_model" require "rails/generators/active_record/migration" @@ -10,7 +12,7 @@ module ActiveRecord # Set the current directory as base for the inherited generators. def self.base_root - File.dirname(__FILE__) + __dir__ end end end diff --git a/activerecord/lib/rails/generators/active_record/application_record/application_record_generator.rb b/activerecord/lib/rails/generators/active_record/application_record/application_record_generator.rb new file mode 100644 index 0000000000..35d5664400 --- /dev/null +++ b/activerecord/lib/rails/generators/active_record/application_record/application_record_generator.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails/generators/active_record" + +module ActiveRecord + module Generators # :nodoc: + class ApplicationRecordGenerator < ::Rails::Generators::Base # :nodoc: + source_root File.expand_path("templates", __dir__) + + # FIXME: Change this file to a symlink once RubyGems 2.5.0 is required. + def create_application_record + template "application_record.rb", application_record_file_name + end + + private + + def application_record_file_name + @application_record_file_name ||= + if namespaced? + "app/models/#{namespaced_path}/application_record.rb" + else + "app/models/application_record.rb" + end + end + end + end +end diff --git a/activerecord/lib/rails/generators/active_record/model/templates/application_record.rb b/activerecord/lib/rails/generators/active_record/application_record/templates/application_record.rb.tt index 60050e0bf8..60050e0bf8 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/application_record.rb +++ b/activerecord/lib/rails/generators/active_record/application_record/templates/application_record.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/migration.rb b/activerecord/lib/rails/generators/active_record/migration.rb index 43075077b9..4a17082d66 100644 --- a/activerecord/lib/rails/generators/active_record/migration.rb +++ b/activerecord/lib/rails/generators/active_record/migration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails/generators/migration" module ActiveRecord @@ -22,7 +24,9 @@ module ActiveRecord end def db_migrate_path - if defined?(Rails) && Rails.application + if migrations_paths = options[:migrations_paths] + migrations_paths + elsif defined?(Rails.application) && Rails.application Rails.application.config.paths["db/migrate"].to_ary.first else "db/migrate" diff --git a/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb b/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb index 1f1c47499b..281b7afb50 100644 --- a/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb +++ b/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails/generators/active_record" module ActiveRecord @@ -6,6 +8,7 @@ module ActiveRecord argument :attributes, type: :array, default: [], banner: "field[:type][:index] field[:type][:index]" class_option :primary_key_type, type: :string, desc: "The type for primary key" + class_option :migrations_paths, type: :string, desc: "The migration path for your generated migrations. If this is not set it will default to db/migrate" def create_migration_file set_local_assigns! @@ -13,12 +16,8 @@ module ActiveRecord migration_template @migration_template, File.join(db_migrate_path, "#{file_name}.rb") end - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - attr_reader :migration_action, :join_tables - private + attr_reader :migration_action, :join_tables # Sets the default migration template that is being used for the generation of the migration. # Depending on command line arguments, the migration template and the table name instance @@ -26,7 +25,7 @@ module ActiveRecord def set_local_assigns! @migration_template = "migration.rb" case file_name - when /^(add|remove)_.*_(?:to|from)_(.*)/ + when /^(add)_.*_to_(.*)/, /^(remove)_.*?_from_(.*)/ @migration_action = $1 @table_name = normalize_table_name($2) when /join_table/ diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt index 5f7201cfe1..5f7201cfe1 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb +++ b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt index 481c70201b..481c70201b 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb +++ b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/model/model_generator.rb b/activerecord/lib/rails/generators/active_record/model/model_generator.rb index 5cec07d2e3..25e54f3ac8 100644 --- a/activerecord/lib/rails/generators/active_record/model/model_generator.rb +++ b/activerecord/lib/rails/generators/active_record/model/model_generator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails/generators/active_record" module ActiveRecord @@ -21,13 +23,11 @@ module ActiveRecord end def create_model_file - generate_application_record template "model.rb", File.join("app/models", class_path, "#{file_name}.rb") end def create_module_file return if regular_class_path.empty? - generate_application_record template "module.rb", File.join("app/models", "#{class_path.join('/')}.rb") if behavior == :invoke end @@ -39,31 +39,10 @@ module ActiveRecord attributes.select { |a| !a.reference? && a.has_index? } end - # FIXME: Change this file to a symlink once RubyGems 2.5.0 is required. - def generate_application_record - if behavior == :invoke && !application_record_exist? - template "application_record.rb", application_record_file_name - end - end - # Used by the migration template to determine the parent name of the model def parent_class_name options[:parent] || "ApplicationRecord" end - - def application_record_exist? - file_exist = nil - in_root { file_exist = File.exist?(application_record_file_name) } - file_exist - end - - def application_record_file_name - @application_record_file_name ||= if mountable_engine? - "app/models/#{namespaced_path}/application_record.rb" - else - "app/models/application_record.rb" - end - end end end end diff --git a/activerecord/lib/rails/generators/active_record/model/templates/model.rb b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt index 55dc65c8ad..55dc65c8ad 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/model.rb +++ b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/model/templates/module.rb b/activerecord/lib/rails/generators/active_record/model/templates/module.rb.tt index a3bf1c37b6..a3bf1c37b6 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/module.rb +++ b/activerecord/lib/rails/generators/active_record/model/templates/module.rb.tt diff --git a/activerecord/test/.gitignore b/activerecord/test/.gitignore deleted file mode 100644 index a0ec5967dd..0000000000 --- a/activerecord/test/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/config.yml diff --git a/activerecord/test/active_record/connection_adapters/fake_adapter.rb b/activerecord/test/active_record/connection_adapters/fake_adapter.rb index b0d8050721..f977b2997b 100644 --- a/activerecord/test/active_record/connection_adapters/fake_adapter.rb +++ b/activerecord/test/active_record/connection_adapters/fake_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ConnectionHandling def fake_connection(config) diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 0c9d1dff9d..a93e5e2b40 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "cases/helper" +require "support/connection_helper" require "models/book" require "models/post" require "models/author" @@ -8,6 +11,7 @@ module ActiveRecord class AdapterTest < ActiveRecord::TestCase def setup @connection = ActiveRecord::Base.connection + @connection.materialize_transactions end ## @@ -18,7 +22,7 @@ module ActiveRecord b = Book.create(name: "my \x00 book") b.reload assert_equal "my \x00 book", b.name - b.update_attributes(name: "my other \x00 book") + b.update(name: "my other \x00 book") b.reload assert_equal "my other \x00 book", b.name end @@ -30,6 +34,16 @@ module ActiveRecord assert_nothing_raised { Book.destroy(0) } end + def test_valid_column + @connection.native_database_types.each_key do |type| + assert @connection.valid_type?(type) + end + end + + def test_invalid_column + assert_not @connection.valid_type?(:foobar) + end + def test_tables tables = @connection.tables assert_includes tables, "accounts" @@ -66,13 +80,13 @@ module ActiveRecord idx_name = "accounts_idx" indexes = @connection.indexes("accounts") - assert indexes.empty? + assert_empty indexes @connection.add_index :accounts, :firm_id, name: idx_name indexes = @connection.indexes("accounts") assert_equal "accounts", indexes.first.table assert_equal idx_name, indexes.first.name - assert !indexes.first.unique + assert_not indexes.first.unique assert_equal ["firm_id"], indexes.first.columns ensure @connection.remove_index(:accounts, name: idx_name) rescue nil @@ -146,26 +160,6 @@ module ActiveRecord end end - # test resetting sequences in odd tables in PostgreSQL - if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!) - require "models/movie" - require "models/subscriber" - - def test_reset_empty_table_with_custom_pk - Movie.delete_all - Movie.connection.reset_pk_sequence! "movies" - assert_equal 1, Movie.create(name: "fight club").id - end - - def test_reset_table_with_non_integer_pk - Subscriber.delete_all - Subscriber.connection.reset_pk_sequence! "subscribers" - sub = Subscriber.new(name: "robert drake") - sub.id = "bob drake" - assert_nothing_raised { sub.save! } - end - end - def test_uniqueness_violations_are_translated_to_specific_exception @connection.execute "INSERT INTO subscribers(nick) VALUES('me')" error = assert_raises(ActiveRecord::RecordNotUnique) do @@ -201,16 +195,78 @@ module ActiveRecord end end + def test_exceptions_from_notifications_are_not_translated + original_error = StandardError.new("This StandardError shouldn't get translated") + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") { raise original_error } + actual_error = assert_raises(StandardError) do + @connection.execute("SELECT * FROM posts") + end + + assert_equal original_error, actual_error + + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_database_related_exceptions_are_translated_to_statement_invalid + error = assert_raises(ActiveRecord::StatementInvalid) do + @connection.execute("This is a syntax error") + end + + assert_instance_of ActiveRecord::StatementInvalid, error + assert_kind_of Exception, error.cause + end + def test_select_all_always_return_activerecord_result result = @connection.select_all "SELECT * FROM posts" assert result.is_a?(ActiveRecord::Result) end + if ActiveRecord::Base.connection.prepared_statements + def test_select_all_with_legacy_binds + post = Post.create!(title: "foo", body: "bar") + expected = @connection.select_all("SELECT * FROM posts WHERE id = #{post.id}") + result = @connection.select_all("SELECT * FROM posts WHERE id = #{Arel::Nodes::BindParam.new(nil).to_sql}", nil, [[nil, post.id]]) + assert_equal expected.to_hash, result.to_hash + end + + def test_insert_update_delete_with_legacy_binds + binds = [[nil, 1]] + bind_param = Arel::Nodes::BindParam.new(nil) + + id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds) + assert_equal 1, id + + @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_equal({ "id" => 1, "title" => "foo" }, result.first) + + @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_nil result.first + end + + def test_insert_update_delete_with_binds + binds = [Relation::QueryAttribute.new("id", 1, Type.default_value)] + bind_param = Arel::Nodes::BindParam.new(nil) + + id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds) + assert_equal 1, id + + @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_equal({ "id" => 1, "title" => "foo" }, result.first) + + @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_nil result.first + end + end + def test_select_methods_passing_a_association_relation author = Author.create!(name: "john") Post.create!(author: author, title: "foo", body: "bar") query = author.posts.where(title: "foo").select(:title) - assert_equal({ "title" => "foo" }, @connection.select_one(query.arel, nil, query.bound_attributes)) assert_equal({ "title" => "foo" }, @connection.select_one(query)) assert @connection.select_all(query).is_a?(ActiveRecord::Result) assert_equal "foo", @connection.select_value(query) @@ -220,7 +276,6 @@ module ActiveRecord def test_select_methods_passing_a_relation Post.create!(title: "foo", body: "bar") query = Post.where(title: "foo").select(:title) - assert_equal({ "title" => "foo" }, @connection.select_one(query.arel, nil, query.bound_attributes)) assert_equal({ "title" => "foo" }, @connection.select_one(query)) assert @connection.select_all(query).is_a?(ActiveRecord::Result) assert_equal "foo", @connection.select_value(query) @@ -235,18 +290,52 @@ module ActiveRecord def test_log_invalid_encoding error = assert_raises RuntimeError do @connection.send :log, "SELECT 'ы' FROM DUAL" do - raise "ы".force_encoding(Encoding::ASCII_8BIT) + raise "ы".dup.force_encoding(Encoding::ASCII_8BIT) end end - assert_not_nil error.message + assert_equal "ы", error.message end end + + def test_supports_multi_insert_is_deprecated + assert_deprecated { @connection.supports_multi_insert? } + end + + def test_column_name_length_is_deprecated + assert_deprecated { @connection.column_name_length } + end + + def test_table_name_length_is_deprecated + assert_deprecated { @connection.table_name_length } + end + + def test_columns_per_table_is_deprecated + assert_deprecated { @connection.columns_per_table } + end + + def test_indexes_per_table_is_deprecated + assert_deprecated { @connection.indexes_per_table } + end + + def test_columns_per_multicolumn_index_is_deprecated + assert_deprecated { @connection.columns_per_multicolumn_index } + end + + def test_sql_query_length_is_deprecated + assert_deprecated { @connection.sql_query_length } + end + + def test_joins_per_query_is_deprecated + assert_deprecated { @connection.joins_per_query } + end end class AdapterForeignKeyTest < ActiveRecord::TestCase self.use_transactional_tests = false + fixtures :fk_test_has_pk + def setup @connection = ActiveRecord::Base.connection end @@ -265,7 +354,7 @@ module ActiveRecord assert_not_nil error.cause end - def test_foreign_key_violations_are_translated_to_specific_exception + def test_foreign_key_violations_on_insert_are_translated_to_specific_exception error = assert_raises(ActiveRecord::InvalidForeignKey) do insert_into_fk_test_has_fk end @@ -273,6 +362,16 @@ module ActiveRecord assert_not_nil error.cause end + def test_foreign_key_violations_on_delete_are_translated_to_specific_exception + insert_into_fk_test_has_fk fk_id: 1 + + error = assert_raises(ActiveRecord::InvalidForeignKey) do + @connection.execute "DELETE FROM fk_test_has_pk WHERE pk_id = 1" + end + + assert_not_nil error.cause + end + def test_disable_referential_integrity assert_nothing_raised do @connection.disable_referential_integrity do @@ -285,14 +384,13 @@ module ActiveRecord end private - - def insert_into_fk_test_has_fk + def insert_into_fk_test_has_fk(fk_id: 0) # Oracle adapter uses prefetched primary key values from sequence and passes them to connection adapter insert method if @connection.prefetch_primary_key? id_value = @connection.next_sequence_value(@connection.default_sequence_name("fk_test_has_fk", "id")) - @connection.execute "INSERT INTO fk_test_has_fk (id,fk_id) VALUES (#{id_value},0)" + @connection.execute "INSERT INTO fk_test_has_fk (id,fk_id) VALUES (#{id_value},#{fk_id})" else - @connection.execute "INSERT INTO fk_test_has_fk (fk_id) VALUES (0)" + @connection.execute "INSERT INTO fk_test_has_fk (fk_id) VALUES (#{fk_id})" end end end @@ -315,16 +413,60 @@ module ActiveRecord unless in_memory_db? test "transaction state is reset after a reconnect" do @connection.begin_transaction - assert @connection.transaction_open? + assert_predicate @connection, :transaction_open? @connection.reconnect! - assert !@connection.transaction_open? + assert_not_predicate @connection, :transaction_open? end test "transaction state is reset after a disconnect" do @connection.begin_transaction - assert @connection.transaction_open? + assert_predicate @connection, :transaction_open? @connection.disconnect! - assert !@connection.transaction_open? + assert_not_predicate @connection, :transaction_open? + end + end + + # test resetting sequences in odd tables in PostgreSQL + if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!) + require "models/movie" + require "models/subscriber" + + def test_reset_empty_table_with_custom_pk + Movie.delete_all + Movie.connection.reset_pk_sequence! "movies" + assert_equal 1, Movie.create(name: "fight club").id + end + + def test_reset_table_with_non_integer_pk + Subscriber.delete_all + Subscriber.connection.reset_pk_sequence! "subscribers" + sub = Subscriber.new(name: "robert drake") + sub.id = "bob drake" + assert_nothing_raised { sub.save! } + end + end + end +end + +if ActiveRecord::Base.connection.supports_advisory_locks? + class AdvisoryLocksEnabledTest < ActiveRecord::TestCase + include ConnectionHelper + + def test_advisory_locks_enabled? + assert ActiveRecord::Base.connection.advisory_locks_enabled? + + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection( + orig_connection.merge(advisory_locks: false) + ) + + assert_not ActiveRecord::Base.connection.advisory_locks_enabled? + + ActiveRecord::Base.establish_connection( + orig_connection.merge(advisory_locks: true) + ) + + assert ActiveRecord::Base.connection.advisory_locks_enabled? 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 67e1efde27..261fee13eb 100644 --- a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -7,7 +9,7 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase def setup ActiveRecord::Base.connection.singleton_class.class_eval do alias_method :execute_without_stub, :execute - def execute(sql, name = nil) return sql end + def execute(sql, name = nil) sql end end end @@ -66,14 +68,14 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase def (ActiveRecord::Base.connection).data_source_exists?(*); false; end %w(SPATIAL FULLTEXT UNIQUE).each do |type| - expected = "CREATE TABLE `people` (#{type} INDEX `index_people_on_last_name` (`last_name`)) ENGINE=InnoDB" + expected = "CREATE TABLE `people` (#{type} INDEX `index_people_on_last_name` (`last_name`))" actual = ActiveRecord::Base.connection.create_table(:people, id: false) do |t| t.index :last_name, type: type end assert_equal expected, actual end - expected = "CREATE TABLE `people` ( INDEX `index_people_on_last_name` USING btree (`last_name`(10))) ENGINE=InnoDB" + expected = "CREATE TABLE `people` ( INDEX `index_people_on_last_name` USING btree (`last_name`(10)))" actual = ActiveRecord::Base.connection.create_table(:people, id: false) do |t| t.index :last_name, length: 10, using: :btree end @@ -104,9 +106,9 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase end def test_create_mysql_database_with_encoding - assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8`", create_database(:matt) + assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8mb4`", create_database(:matt) assert_equal "CREATE DATABASE `aimonetti` DEFAULT CHARACTER SET `latin1`", create_database(:aimonetti, charset: "latin1") - assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT CHARACTER SET `big5` COLLATE `big5_chinese_ci`", create_database(:matt_aimonetti, charset: :big5, collation: :big5_chinese_ci) + assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT COLLATE `utf8mb4_bin`", create_database(:matt_aimonetti, collation: "utf8mb4_bin") end def test_recreate_mysql_database_with_encoding @@ -146,8 +148,8 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase t.timestamps null: true end ActiveRecord::Base.connection.remove_timestamps :delete_me, null: true - assert !column_present?("delete_me", "updated_at", "datetime") - assert !column_present?("delete_me", "created_at", "datetime") + assert_not column_present?("delete_me", "updated_at", "datetime") + assert_not column_present?("delete_me", "created_at", "datetime") ensure ActiveRecord::Base.connection.drop_table :delete_me rescue nil end @@ -155,15 +157,19 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase end def test_indexes_in_create - ActiveRecord::Base.connection.stubs(:data_source_exists?).with(:temp).returns(false) - ActiveRecord::Base.connection.stubs(:index_name_exists?).with(:index_temp_on_zip).returns(false) + assert_called_with( + ActiveRecord::Base.connection, + :data_source_exists?, + [:temp], + returns: false + ) do + expected = "CREATE TEMPORARY TABLE `temp` ( INDEX `index_temp_on_zip` (`zip`)) AS SELECT id, name, zip FROM a_really_complicated_query" + actual = ActiveRecord::Base.connection.create_table(:temp, temporary: true, as: "SELECT id, name, zip FROM a_really_complicated_query") do |t| + t.index :zip + end - expected = "CREATE TEMPORARY TABLE `temp` ( INDEX `index_temp_on_zip` (`zip`)) ENGINE=InnoDB AS SELECT id, name, zip FROM a_really_complicated_query" - actual = ActiveRecord::Base.connection.create_table(:temp, temporary: true, as: "SELECT id, name, zip FROM a_really_complicated_query") do |t| - t.index :zip + assert_equal expected, actual end - - assert_equal expected, actual end private diff --git a/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb b/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb new file mode 100644 index 0000000000..4c67633946 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class Mysql2AutoIncrementTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table :auto_increments, if_exists: true + end + + def test_auto_increment_without_primary_key + @connection.create_table :auto_increments, id: false, force: true do |t| + t.integer :id, null: false, auto_increment: true + t.index :id + end + output = dump_table_schema("auto_increments") + assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output) + end + + def test_auto_increment_with_composite_primary_key + @connection.create_table :auto_increments, primary_key: [:id, :created_at], force: true do |t| + t.integer :id, null: false, auto_increment: true + t.datetime :created_at, null: false + end + output = dump_table_schema("auto_increments") + assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output) + end +end diff --git a/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb index 8f7c803a21..825bddfb73 100644 --- a/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb +++ b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" diff --git a/activerecord/test/cases/adapters/mysql2/boolean_test.rb b/activerecord/test/cases/adapters/mysql2/boolean_test.rb index 2fa39282fb..db09b30361 100644 --- a/activerecord/test/cases/adapters/mysql2/boolean_test.rb +++ b/activerecord/test/cases/adapters/mysql2/boolean_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Mysql2BooleanTest < ActiveRecord::Mysql2TestCase @@ -38,7 +40,7 @@ class Mysql2BooleanTest < ActiveRecord::Mysql2TestCase assert_equal :string, string_column.type end - test "test type casting with emulated booleans" do + test "type casting with emulated booleans" do emulate_booleans true boolean = BooleanType.create!(archived: true, published: true) @@ -55,7 +57,7 @@ class Mysql2BooleanTest < ActiveRecord::Mysql2TestCase assert_equal 0, @connection.type_cast(false) end - test "test type casting without emulated booleans" do + test "type casting without emulated booleans" do emulate_booleans false boolean = BooleanType.create!(archived: true, published: true) diff --git a/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb index 50ba9ab831..aa870349be 100644 --- a/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb +++ b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Mysql2CaseSensitivityTest < ActiveRecord::Mysql2TestCase @@ -12,8 +14,8 @@ class Mysql2CaseSensitivityTest < ActiveRecord::Mysql2TestCase end def test_case_sensitive - assert !CollationTest.columns_hash["string_ci_column"].case_sensitive? - assert CollationTest.columns_hash["string_cs_column"].case_sensitive? + assert_not_predicate CollationTest.columns_hash["string_ci_column"], :case_sensitive? + assert_predicate CollationTest.columns_hash["string_cs_column"], :case_sensitive? end def test_case_insensitive_comparison_for_ci_column diff --git a/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb index 8826ad7fd1..d0c57de65d 100644 --- a/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb +++ b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -48,7 +50,7 @@ class Mysql2CharsetCollationTest < ActiveRecord::Mysql2TestCase test "schema dump includes collation" do output = dump_table_schema("charset_collations") - assert_match %r{t.string\s+"string_ascii_bin",\s+collation: "ascii_bin"$}, output - assert_match %r{t.text\s+"text_ucs2_unicode_ci",\s+collation: "ucs2_unicode_ci"$}, output + assert_match %r{t\.string\s+"string_ascii_bin",\s+collation: "ascii_bin"$}, output + assert_match %r{t\.text\s+"text_ucs2_unicode_ci",\s+collation: "ucs2_unicode_ci"$}, output end end diff --git a/activerecord/test/cases/adapters/mysql2/connection_test.rb b/activerecord/test/cases/adapters/mysql2/connection_test.rb index a2faf43b0d..3103589186 100644 --- a/activerecord/test/cases/adapters/mysql2/connection_test.rb +++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -38,41 +40,29 @@ class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase end def test_no_automatic_reconnection_after_timeout - assert @connection.active? + assert_predicate @connection, :active? @connection.update("set @@wait_timeout=1") sleep 2 - assert !@connection.active? + assert_not_predicate @connection, :active? ensure # Repair all fixture connections so other tests won't break. @fixture_connections.each(&:verify!) end def test_successful_reconnection_after_timeout_with_manual_reconnect - assert @connection.active? + assert_predicate @connection, :active? @connection.update("set @@wait_timeout=1") sleep 2 @connection.reconnect! - assert @connection.active? + assert_predicate @connection, :active? end def test_successful_reconnection_after_timeout_with_verify - assert @connection.active? + assert_predicate @connection, :active? @connection.update("set @@wait_timeout=1") sleep 2 @connection.verify! - assert @connection.active? - end - - def test_verify_with_args_is_deprecated - assert_deprecated do - @connection.verify!(option: true) - end - assert_deprecated do - @connection.verify!([]) - end - assert_deprecated do - @connection.verify!({}) - end + assert_predicate @connection, :active? end def test_execute_after_disconnect @@ -114,8 +104,8 @@ class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase end def test_mysql_connection_collation_is_configured - assert_equal "utf8_unicode_ci", @connection.show_variable("collation_connection") - assert_equal "utf8_general_ci", ARUnit2Model.connection.show_variable("collation_connection") + assert_equal "utf8mb4_unicode_ci", @connection.show_variable("collation_connection") + assert_equal "utf8mb4_general_ci", ARUnit2Model.connection.show_variable("collation_connection") end def test_mysql_default_in_strict_mode @@ -180,14 +170,16 @@ class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase end def test_logs_name_show_variable + ActiveRecord::Base.connection.materialize_transactions + @subscriber.logged.clear @connection.show_variable "foo" assert_equal "SCHEMA", @subscriber.logged[0][1] end - def test_logs_name_rename_column_sql + def test_logs_name_rename_column_for_alter @connection.execute "CREATE TABLE `bar_baz` (`foo` varchar(255))" @subscriber.logged.clear - @connection.send(:rename_column_sql, "bar_baz", "foo", "foo2") + @connection.send(:rename_column_for_alter, "bar_baz", "foo", "foo2") assert_equal "SCHEMA", @subscriber.logged[0][1] ensure @connection.execute "DROP TABLE `bar_baz`" 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 c131a5169c..00a075e063 100644 --- a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb +++ b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Mysql2DatetimePrecisionQuotingTest < ActiveRecord::Mysql2TestCase @@ -43,9 +45,10 @@ class Mysql2DatetimePrecisionQuotingTest < ActiveRecord::Mysql2TestCase end def stub_version(full_version_string) - @connection.stubs(:full_version).returns(full_version_string) - @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) - yield + @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 diff --git a/activerecord/test/cases/adapters/mysql2/enum_test.rb b/activerecord/test/cases/adapters/mysql2/enum_test.rb index 7ad3e3ca2d..832f5d61d1 100644 --- a/activerecord/test/cases/adapters/mysql2/enum_test.rb +++ b/activerecord/test/cases/adapters/mysql2/enum_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Mysql2EnumTest < ActiveRecord::Mysql2TestCase @@ -11,11 +13,11 @@ class Mysql2EnumTest < ActiveRecord::Mysql2TestCase def test_should_not_be_unsigned column = EnumTest.columns_hash["enum_column"] - assert_not column.unsigned? + assert_not_predicate column, :unsigned? end def test_should_not_be_bigint column = EnumTest.columns_hash["enum_column"] - assert_not column.bigint? + assert_not_predicate column, :bigint? end end diff --git a/activerecord/test/cases/adapters/mysql2/explain_test.rb b/activerecord/test/cases/adapters/mysql2/explain_test.rb index 7916921e5a..b8e778f0b0 100644 --- a/activerecord/test/cases/adapters/mysql2/explain_test.rb +++ b/activerecord/test/cases/adapters/mysql2/explain_test.rb @@ -1,21 +1,23 @@ +# frozen_string_literal: true + require "cases/helper" -require "models/developer" -require "models/computer" +require "models/author" +require "models/post" class Mysql2ExplainTest < ActiveRecord::Mysql2TestCase - fixtures :developers + fixtures :authors def test_explain_for_one_query - explain = Developer.where(id: 1).explain - assert_match %(EXPLAIN for: SELECT `developers`.* FROM `developers` WHERE `developers`.`id` = 1), explain - assert_match %r(developers |.* const), explain + explain = Author.where(id: 1).explain + assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain + assert_match %r(authors |.* const), explain end def test_explain_with_eager_loading - explain = Developer.where(id: 1).includes(:audit_logs).explain - assert_match %(EXPLAIN for: SELECT `developers`.* FROM `developers` WHERE `developers`.`id` = 1), explain - assert_match %r(developers |.* const), explain - assert_match %(EXPLAIN for: SELECT `audit_logs`.* FROM `audit_logs` WHERE `audit_logs`.`developer_id` = 1), explain - assert_match %r(audit_logs |.* ALL), explain + explain = Author.where(id: 1).includes(:posts).explain + assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain + assert_match %r(authors |.* const), explain + assert_match %(EXPLAIN for: SELECT `posts`.* FROM `posts` WHERE `posts`.`author_id` = 1), explain + assert_match %r(posts |.* ALL), explain end end diff --git a/activerecord/test/cases/adapters/mysql2/json_test.rb b/activerecord/test/cases/adapters/mysql2/json_test.rb index 6954006003..de78ba91f5 100644 --- a/activerecord/test/cases/adapters/mysql2/json_test.rb +++ b/activerecord/test/cases/adapters/mysql2/json_test.rb @@ -1,195 +1,24 @@ +# frozen_string_literal: true + require "cases/helper" -require "support/schema_dumping_helper" +require "cases/json_shared_test_cases" if ActiveRecord::Base.connection.supports_json? class Mysql2JSONTest < ActiveRecord::Mysql2TestCase - include SchemaDumpingHelper + include JSONSharedTestCases self.use_transactional_tests = false - class JsonDataType < ActiveRecord::Base - self.table_name = "json_data_type" - - store_accessor :settings, :resolution - end - def setup - @connection = ActiveRecord::Base.connection - begin - @connection.create_table("json_data_type") do |t| - t.json "payload" - t.json "settings" - end + super + @connection.create_table("json_data_type") do |t| + t.json "payload" + t.json "settings" end end - def teardown - @connection.drop_table :json_data_type, if_exists: true - JsonDataType.reset_column_information - end - - def test_column - column = JsonDataType.columns_hash["payload"] - assert_equal :json, column.type - assert_equal "json", column.sql_type - - type = JsonDataType.type_for_attribute("payload") - assert_not type.binary? - end - - def test_change_table_supports_json - @connection.change_table("json_data_type") do |t| - t.json "users" + private + def column_type + :json end - JsonDataType.reset_column_information - column = JsonDataType.columns_hash["users"] - assert_equal :json, column.type - end - - def test_schema_dumping - output = dump_table_schema("json_data_type") - assert_match(/t\.json\s+"settings"/, output) - end - - def test_cast_value_on_write - x = JsonDataType.new payload: { "string" => "foo", :symbol => :bar } - assert_equal({ "string" => "foo", :symbol => :bar }, x.payload_before_type_cast) - assert_equal({ "string" => "foo", "symbol" => "bar" }, x.payload) - x.save - assert_equal({ "string" => "foo", "symbol" => "bar" }, x.reload.payload) - end - - def test_type_cast_json - type = JsonDataType.type_for_attribute("payload") - - data = "{\"a_key\":\"a_value\"}" - hash = type.deserialize(data) - assert_equal({ "a_key" => "a_value" }, hash) - assert_equal({ "a_key" => "a_value" }, type.deserialize(data)) - - assert_equal({}, type.deserialize("{}")) - assert_equal({ "key" => nil }, type.deserialize('{"key": null}')) - assert_equal({ "c" => "}", '"a"' => 'b "a b' }, type.deserialize(%q({"c":"}", "\"a\"":"b \"a b"}))) - end - - def test_rewrite - @connection.execute "insert into json_data_type (payload) VALUES ('{\"k\":\"v\"}')" - x = JsonDataType.first - x.payload = { '"a\'' => "b" } - assert x.save! - end - - def test_select - @connection.execute "insert into json_data_type (payload) VALUES ('{\"k\":\"v\"}')" - x = JsonDataType.first - assert_equal({ "k" => "v" }, x.payload) - end - - def test_select_multikey - @connection.execute %q|insert into json_data_type (payload) VALUES ('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')| - x = JsonDataType.first - assert_equal({ "k1" => "v1", "k2" => "v2", "k3" => [1, 2, 3] }, x.payload) - end - - def test_null_json - @connection.execute "insert into json_data_type (payload) VALUES(null)" - x = JsonDataType.first - assert_nil(x.payload) - end - - def test_select_array_json_value - @connection.execute %q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')| - x = JsonDataType.first - assert_equal(["v0", { "k1" => "v1" }], x.payload) - end - - def test_select_nil_json_after_create - json = JsonDataType.create(payload: nil) - x = JsonDataType.where(payload: nil).first - assert_equal(json, x) - end - - def test_select_nil_json_after_update - json = JsonDataType.create(payload: "foo") - x = JsonDataType.where(payload: nil).first - assert_nil(x) - - json.update_attributes payload: nil - x = JsonDataType.where(payload: nil).first - assert_equal(json.reload, x) - end - - def test_rewrite_array_json_value - @connection.execute %q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')| - x = JsonDataType.first - x.payload = ["v1", { "k2" => "v2" }, "v3"] - assert x.save! - end - - def test_with_store_accessors - x = JsonDataType.new(resolution: "320×480") - assert_equal "320×480", x.resolution - - x.save! - x = JsonDataType.first - assert_equal "320×480", x.resolution - - x.resolution = "640×1136" - x.save! - - x = JsonDataType.first - assert_equal "640×1136", x.resolution - end - - def test_duplication_with_store_accessors - x = JsonDataType.new(resolution: "320×480") - assert_equal "320×480", x.resolution - - y = x.dup - assert_equal "320×480", y.resolution - end - - def test_yaml_round_trip_with_store_accessors - x = JsonDataType.new(resolution: "320×480") - assert_equal "320×480", x.resolution - - y = YAML.load(YAML.dump(x)) - assert_equal "320×480", y.resolution - end - - def test_changes_in_place - json = JsonDataType.new - assert_not json.changed? - - json.payload = { "one" => "two" } - assert json.changed? - assert json.payload_changed? - - json.save! - assert_not json.changed? - - json.payload["three"] = "four" - assert json.payload_changed? - - json.save! - json.reload - - assert_equal({ "one" => "two", "three" => "four" }, json.payload) - assert_not json.changed? - end - - def test_assigning_string_literal - json = JsonDataType.create(payload: "foo") - assert_equal "foo", json.payload - end - - def test_assigning_number - json = JsonDataType.create(payload: 1.234) - assert_equal 1.234, json.payload - end - - def test_assigning_boolean - json = JsonDataType.create(payload: true) - assert_equal true, json.payload - end end end diff --git a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb index aab3dcb724..0719baaa23 100644 --- a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb +++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/ddl_helper" @@ -17,42 +19,31 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase end end - def test_valid_column - with_example_table do - column = @conn.columns("ex").find { |col| col.name == "id" } - assert @conn.valid_type?(column.type) - end - end - - def test_invalid_column - assert_not @conn.valid_type?(:foobar) - end - def test_columns_for_distinct_zero_orders assert_equal "posts.id", @conn.columns_for_distinct("posts.id", []) end def test_columns_for_distinct_one_order - assert_equal "posts.id, posts.created_at AS alias_0", + assert_equal "posts.created_at AS alias_0, posts.id", @conn.columns_for_distinct("posts.id", ["posts.created_at desc"]) end def test_columns_for_distinct_few_orders - assert_equal "posts.id, posts.created_at AS alias_0, posts.position AS alias_1", + assert_equal "posts.created_at AS alias_0, posts.position AS alias_1, posts.id", @conn.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"]) end def test_columns_for_distinct_with_case assert_equal( - "posts.id, CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0", + "CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0, posts.id", @conn.columns_for_distinct("posts.id", ["CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END"]) ) end def test_columns_for_distinct_blank_not_nil_orders - assert_equal "posts.id, posts.created_at AS alias_0", + assert_equal "posts.created_at AS alias_0, posts.id", @conn.columns_for_distinct("posts.id", ["posts.created_at desc", "", " "]) end @@ -61,7 +52,7 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase def order.to_sql "posts.created_at desc" end - assert_equal "posts.id, posts.created_at AS alias_0", + assert_equal "posts.created_at AS alias_0, posts.id", @conn.columns_for_distinct("posts.id", [order]) end diff --git a/activerecord/test/cases/adapters/mysql2/reserved_word_test.rb b/activerecord/test/cases/adapters/mysql2/reserved_word_test.rb deleted file mode 100644 index 2c778b1150..0000000000 --- a/activerecord/test/cases/adapters/mysql2/reserved_word_test.rb +++ /dev/null @@ -1,151 +0,0 @@ -require "cases/helper" - -# a suite of tests to ensure the ConnectionAdapters#MysqlAdapter can handle tables with -# reserved word names (ie: group, order, values, etc...) -class Mysql2ReservedWordTest < ActiveRecord::Mysql2TestCase - class Group < ActiveRecord::Base - Group.table_name = "group" - belongs_to :select - has_one :values - end - - class Select < ActiveRecord::Base - Select.table_name = "select" - has_many :groups - end - - class Values < ActiveRecord::Base - Values.table_name = "values" - end - - class Distinct < ActiveRecord::Base - Distinct.table_name = "distinct" - has_and_belongs_to_many :selects - has_many :values, through: :groups - end - - def setup - @connection = ActiveRecord::Base.connection - - # we call execute directly here (and do similar below) because ActiveRecord::Base#create_table() - # will fail with these table names if these test cases fail - - create_tables_directly "group" => "id int auto_increment primary key, `order` varchar(255), select_id int", - "select" => "id int auto_increment primary key", - "values" => "id int auto_increment primary key, group_id int", - "distinct" => "id int auto_increment primary key", - "distinct_select" => "distinct_id int, select_id int" - end - - teardown do - drop_tables_directly ["group", "select", "values", "distinct", "distinct_select", "order"] - end - - # create tables with reserved-word names and columns - def test_create_tables - assert_nothing_raised { - @connection.create_table :order do |t| - t.column :group, :string - end - } - end - - # rename tables with reserved-word names - def test_rename_tables - assert_nothing_raised { @connection.rename_table(:group, :order) } - end - - # alter column with a reserved-word name in a table with a reserved-word name - def test_change_columns - assert_nothing_raised { @connection.change_column_default(:group, :order, "whatever") } - #the quoting here will reveal any double quoting issues in change_column's interaction with the column method in the adapter - assert_nothing_raised { @connection.change_column("group", "order", :Int, default: 0) } - assert_nothing_raised { @connection.rename_column(:group, :order, :values) } - end - - # introspect table with reserved word name - def test_introspect - assert_nothing_raised { @connection.columns(:group) } - assert_nothing_raised { @connection.indexes(:group) } - end - - #fixtures - self.use_instantiated_fixtures = true - self.use_transactional_tests = false - - #activerecord model class with reserved-word table name - def test_activerecord_model - create_test_fixtures :select, :distinct, :group, :values, :distinct_select - x = nil - assert_nothing_raised { x = Group.new } - x.order = "x" - assert_nothing_raised { x.save } - x.order = "y" - assert_nothing_raised { x.save } - assert_nothing_raised { Group.find_by_order("y") } - assert_nothing_raised { Group.find(1) } - end - - # has_one association with reserved-word table name - def test_has_one_associations - create_test_fixtures :select, :distinct, :group, :values, :distinct_select - v = nil - assert_nothing_raised { v = Group.find(1).values } - assert_equal 2, v.id - end - - # belongs_to association with reserved-word table name - def test_belongs_to_associations - create_test_fixtures :select, :distinct, :group, :values, :distinct_select - gs = nil - assert_nothing_raised { gs = Select.find(2).groups } - assert_equal gs.length, 2 - assert(gs.collect(&:id).sort == [2, 3]) - end - - # has_and_belongs_to_many with reserved-word table name - def test_has_and_belongs_to_many - create_test_fixtures :select, :distinct, :group, :values, :distinct_select - s = nil - assert_nothing_raised { s = Distinct.find(1).selects } - assert_equal s.length, 2 - assert(s.collect(&:id).sort == [1, 2]) - end - - # activerecord model introspection with reserved-word table and column names - def test_activerecord_introspection - assert_nothing_raised { Group.table_exists? } - assert_nothing_raised { Group.columns } - end - - # Calculations - def test_calculations_work_with_reserved_words - assert_nothing_raised { Group.count } - end - - def test_associations_work_with_reserved_words - assert_nothing_raised { Select.all.merge!(includes: [:groups]).to_a } - end - - #the following functions were added to DRY test cases - - private - # custom fixture loader, uses FixtureSet#create_fixtures and appends base_path to the current file's path - def create_test_fixtures(*fixture_names) - ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/reserved_words", fixture_names) - end - - # custom drop table, uses execute on connection to drop a table if it exists. note: escapes table_name - def drop_tables_directly(table_names, connection = @connection) - table_names.each do |name| - connection.drop_table name, if_exists: true - end - end - - # custom create table, uses execute on connection to create a table, note: escapes table_name, does NOT escape columns - def create_tables_directly(tables, connection = @connection) - tables.each do |table_name, column_properties| - connection.execute("CREATE TABLE `#{table_name}` ( #{column_properties} )") - end - end -end diff --git a/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb index 605baa9905..d7d9a2d732 100644 --- a/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb +++ b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + require "cases/helper" class SchemaMigrationsTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + def test_renaming_index_on_foreign_key connection.add_index "engines", "car_id" connection.add_foreign_key :engines, :cars, name: "fk_engines_cars" @@ -18,7 +22,7 @@ class SchemaMigrationsTest < ActiveRecord::Mysql2TestCase ActiveRecord::SchemaMigration.create_table - assert connection.column_exists?(table_name, :version, :string, collation: "utf8_general_ci") + assert connection.column_exists?(table_name, :version, :string) end end @@ -29,8 +33,10 @@ class SchemaMigrationsTest < ActiveRecord::Mysql2TestCase ActiveRecord::InternalMetadata.create_table - assert connection.column_exists?(table_name, :key, :string, collation: "utf8_general_ci") + assert connection.column_exists?(table_name, :key, :string) end + ensure + ActiveRecord::InternalMetadata[:environment] = connection.migration_context.current_environment end private diff --git a/activerecord/test/cases/adapters/mysql2/schema_test.rb b/activerecord/test/cases/adapters/mysql2/schema_test.rb index 1fad5585de..1283b0642c 100644 --- a/activerecord/test/cases/adapters/mysql2/schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" @@ -65,7 +67,7 @@ module ActiveRecord end def test_data_source_exists_wrong_schema - assert(!@connection.data_source_exists?("#{@db_name}.zomg"), "data_source should not exist") + assert_not(@connection.data_source_exists?("#{@db_name}.zomg"), "data_source should not exist") end def test_dump_indexes diff --git a/activerecord/test/cases/adapters/mysql2/sp_test.rb b/activerecord/test/cases/adapters/mysql2/sp_test.rb index 4182532535..7b6dce71e9 100644 --- a/activerecord/test/cases/adapters/mysql2/sp_test.rb +++ b/activerecord/test/cases/adapters/mysql2/sp_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" @@ -15,7 +17,7 @@ class Mysql2StoredProcedureTest < ActiveRecord::Mysql2TestCase # Test that MySQL allows multiple results for stored procedures # # In MySQL 5.6, CLIENT_MULTI_RESULTS is enabled by default. - # http://dev.mysql.com/doc/refman/5.6/en/call.html + # https://dev.mysql.com/doc/refman/5.6/en/call.html def test_multi_results rows = @connection.select_rows("CALL ten();") assert_equal 10, rows[0][0].to_i, "ten() did not return 10 as expected: #{rows.inspect}" diff --git a/activerecord/test/cases/adapters/mysql2/sql_types_test.rb b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb index d6e7f29a5c..e10642cbb4 100644 --- a/activerecord/test/cases/adapters/mysql2/sql_types_test.rb +++ b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Mysql2SqlTypesTest < ActiveRecord::Mysql2TestCase diff --git a/activerecord/test/cases/adapters/mysql2/table_options_test.rb b/activerecord/test/cases/adapters/mysql2/table_options_test.rb index 61a8ce9bc0..1c92df940f 100644 --- a/activerecord/test/cases/adapters/mysql2/table_options_test.rb +++ b/activerecord/test/cases/adapters/mysql2/table_options_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -15,28 +17,103 @@ class Mysql2TableOptionsTest < ActiveRecord::Mysql2TestCase test "table options with ENGINE" do @connection.create_table "mysql_table_options", force: true, options: "ENGINE=MyISAM" output = dump_table_schema("mysql_table_options") - options = %r{create_table "mysql_table_options", force: :cascade, options: "(?<options>.*)"}.match(output)[:options] + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] assert_match %r{ENGINE=MyISAM}, options end test "table options with ROW_FORMAT" do @connection.create_table "mysql_table_options", force: true, options: "ROW_FORMAT=REDUNDANT" output = dump_table_schema("mysql_table_options") - options = %r{create_table "mysql_table_options", force: :cascade, options: "(?<options>.*)"}.match(output)[:options] + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] assert_match %r{ROW_FORMAT=REDUNDANT}, options end test "table options with CHARSET" do @connection.create_table "mysql_table_options", force: true, options: "CHARSET=utf8mb4" output = dump_table_schema("mysql_table_options") - options = %r{create_table "mysql_table_options", force: :cascade, options: "(?<options>.*)"}.match(output)[:options] + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] assert_match %r{CHARSET=utf8mb4}, options end test "table options with COLLATE" do @connection.create_table "mysql_table_options", force: true, options: "COLLATE=utf8mb4_bin" output = dump_table_schema("mysql_table_options") - options = %r{create_table "mysql_table_options", force: :cascade, options: "(?<options>.*)"}.match(output)[:options] + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] assert_match %r{COLLATE=utf8mb4_bin}, options end end + +class Mysql2DefaultEngineOptionSchemaDumpTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + def setup + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + end + + def teardown + ActiveRecord::Base.connection.drop_table "mysql_table_options", if_exists: true + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + test "schema dump includes ENGINE=InnoDB if not provided" do + ActiveRecord::Base.connection.create_table "mysql_table_options", force: true + + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{ENGINE=InnoDB}, options + end + + test "schema dump includes ENGINE=InnoDB in legacy migrations" do + migration = Class.new(ActiveRecord::Migration[5.1]) do + def migrate(x) + create_table "mysql_table_options", force: true + end + end.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{ENGINE=InnoDB}, options + end +end + +class Mysql2DefaultEngineOptionSqlOutputTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + + def setup + @logger_was = ActiveRecord::Base.logger + @log = StringIO.new + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log) + ActiveRecord::Migration.verbose = false + end + + def teardown + ActiveRecord::Base.logger = @logger_was + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::Base.connection.drop_table "mysql_table_options", if_exists: true + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + test "new migrations do not contain default ENGINE=InnoDB option" do + ActiveRecord::Base.connection.create_table "mysql_table_options", force: true + + assert_no_match %r{ENGINE=InnoDB}, @log.string + end + + test "legacy migrations contain default ENGINE=InnoDB option" do + migration = Class.new(ActiveRecord::Migration[5.1]) do + def migrate(x) + create_table "mysql_table_options", force: true + end + end.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert_match %r{ENGINE=InnoDB}, @log.string + end +end diff --git a/activerecord/test/cases/adapters/mysql2/transaction_test.rb b/activerecord/test/cases/adapters/mysql2/transaction_test.rb index 16101e38cb..52e283f247 100644 --- a/activerecord/test/cases/adapters/mysql2/transaction_test.rb +++ b/activerecord/test/cases/adapters/mysql2/transaction_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -11,6 +13,7 @@ module ActiveRecord setup do @abort, Thread.abort_on_exception = Thread.abort_on_exception, false + Thread.report_on_exception, @original_report_on_exception = false, Thread.report_on_exception @connection = ActiveRecord::Base.connection @connection.clear_cache! @@ -29,6 +32,7 @@ module ActiveRecord @connection.drop_table "samples", if_exists: true Thread.abort_on_exception = @abort + Thread.report_on_exception = @original_report_on_exception end test "raises Deadlocked when a deadlock is encountered" do @@ -42,7 +46,7 @@ module ActiveRecord Sample.transaction do s1.lock! barrier.wait - s2.update_attributes value: 1 + s2.update value: 1 end end @@ -50,7 +54,91 @@ module ActiveRecord Sample.transaction do s2.lock! barrier.wait - s1.update_attributes value: 2 + s1.update value: 2 + end + ensure + thread.join + end + end + end + + test "raises LockWaitTimeout when lock wait timeout exceeded" do + assert_raises(ActiveRecord::LockWaitTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET innodb_lock_wait_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET innodb_lock_wait_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises StatementTimeout when statement timeout exceeded" do + skip unless ActiveRecord::Base.connection.show_variable("max_execution_time") + assert_raises(ActiveRecord::StatementTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET max_execution_time = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET max_execution_time = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when canceling statement due to user request" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch.count_down + sleep(0.5) + conn = Sample.connection + pid = conn.query_value("SELECT id FROM information_schema.processlist WHERE info LIKE '% FOR UPDATE'") + conn.execute("KILL QUERY #{pid}") + end + end + + begin + Sample.transaction do + latch.wait + Sample.lock.find(s.id) end ensure thread.join diff --git a/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb index a0823be143..97da96003d 100644 --- a/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb +++ b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -52,15 +54,15 @@ class Mysql2UnsignedTypeTest < ActiveRecord::Mysql2TestCase end @connection.columns("unsigned_types").select { |c| /^unsigned_/.match?(c.name) }.each do |column| - assert column.unsigned? + assert_predicate column, :unsigned? end end test "schema dump includes unsigned option" do schema = dump_table_schema "unsigned_types" - assert_match %r{t.integer\s+"unsigned_integer",\s+unsigned: true$}, schema - assert_match %r{t.bigint\s+"unsigned_bigint",\s+unsigned: true$}, schema - assert_match %r{t.float\s+"unsigned_float",\s+limit: 24,\s+unsigned: true$}, schema - assert_match %r{t.decimal\s+"unsigned_decimal",\s+precision: 10,\s+scale: 2,\s+unsigned: true$}, schema + assert_match %r{t\.integer\s+"unsigned_integer",\s+unsigned: true$}, schema + assert_match %r{t\.bigint\s+"unsigned_bigint",\s+unsigned: true$}, schema + assert_match %r{t\.float\s+"unsigned_float",\s+unsigned: true$}, schema + assert_match %r{t\.decimal\s+"unsigned_decimal",\s+precision: 10,\s+scale: 2,\s+unsigned: true$}, schema end end diff --git a/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb index 442a4fb7b5..8494acee3b 100644 --- a/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb +++ b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -16,6 +18,7 @@ if ActiveRecord::Base.connection.supports_virtual_columns? t.string :name t.virtual :upper_name, type: :string, as: "UPPER(`name`)" t.virtual :name_length, type: :integer, as: "LENGTH(`name`)", stored: true + t.virtual :name_octet_length, type: :integer, as: "OCTET_LENGTH(`name`)", stored: true end VirtualColumn.create(name: "Rails") end @@ -52,8 +55,9 @@ if ActiveRecord::Base.connection.supports_virtual_columns? def test_schema_dumping output = dump_table_schema("virtual_columns") - assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "UPPER\(`name`\)"$/i, output) - assert_match(/t\.virtual\s+"name_length",\s+type: :integer,\s+as: "LENGTH\(`name`\)",\s+stored: true$/i, output) + assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "(?:UPPER|UCASE)\(`name`\)"$/i, output) + assert_match(/t\.virtual\s+"name_length",\s+type: :integer,\s+as: "(?:octet_length|length)\(`name`\)",\s+stored: true$/i, output) + assert_match(/t\.virtual\s+"name_octet_length",\s+type: :integer,\s+as: "(?:octet_length|length)\(`name`\)",\s+stored: true$/i, output) end end end diff --git a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb index b787de8453..afd422881b 100644 --- a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase def setup + ActiveRecord::Base.connection.materialize_transactions + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.class_eval do def execute(sql, name = nil) sql end end @@ -57,6 +61,15 @@ class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase assert_equal expected, add_index(:people, "lower(last_name)", using: type, unique: true) end + expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING gist ("last_name" bpchar_pattern_ops)) + assert_equal expected, add_index(:people, :last_name, using: :gist, opclass: { last_name: :bpchar_pattern_ops }) + + expected = %(CREATE INDEX "index_people_on_last_name_and_first_name" ON "people" ("last_name" DESC NULLS LAST, "first_name" ASC)) + assert_equal expected, add_index(:people, [:last_name, :first_name], order: { last_name: "DESC NULLS LAST", first_name: :asc }) + + expected = %(CREATE INDEX "index_people_on_last_name" ON "people" ("last_name" NULLS FIRST)) + assert_equal expected, add_index(:people, :last_name, order: "NULLS FIRST") + assert_raise ArgumentError do add_index(:people, :last_name, algorithm: :copy) end diff --git a/activerecord/test/cases/adapters/postgresql/array_test.rb b/activerecord/test/cases/adapters/postgresql/array_test.rb index c78c6178ff..42618c2ec3 100644 --- a/activerecord/test/cases/adapters/postgresql/array_test.rb +++ b/activerecord/test/cases/adapters/postgresql/array_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -37,12 +39,45 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase def test_column assert_equal :string, @column.type assert_equal "character varying(255)", @column.sql_type - assert @column.array? - assert_not @type.binary? + assert_predicate @column, :array? + assert_not_predicate @type, :binary? ratings_column = PgArray.columns_hash["ratings"] assert_equal :integer, ratings_column.type - assert ratings_column.array? + assert_predicate ratings_column, :array? + end + + def test_not_compatible_with_serialize_array + new_klass = Class.new(PgArray) do + serialize :tags, Array + end + assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do + new_klass.new + end + end + + class MyTags + def initialize(tags); @tags = tags end + def to_a; @tags end + def self.load(tags); new(tags) end + def self.dump(object); object.to_a end + end + + def test_array_with_serialized_attributes + new_klass = Class.new(PgArray) do + serialize :tags, MyTags + end + + new_klass.create!(tags: MyTags.new(["one", "two"])) + record = new_klass.first + + assert_instance_of MyTags, record.tags + assert_equal ["one", "two"], record.tags.to_a + + record.tags = MyTags.new(["three", "four"]) + record.save! + + assert_equal ["three", "four"], record.reload.tags.to_a end def test_default @@ -74,7 +109,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase assert_equal :text, column.type assert_equal [], PgArray.column_defaults["snippets"] - assert column.array? + assert_predicate column, :array? end def test_change_column_cant_make_non_array_column_to_array @@ -191,6 +226,14 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase assert_equal(PgArray.last.tags, tag_values) end + def test_insert_fixtures + tag_values = ["val1", "val2", "val3_with_'_multiple_quote_'_chars"] + assert_deprecated do + @connection.insert_fixtures([{ "tags" => tag_values }], "pg_arrays") + end + assert_equal(PgArray.last.tags, tag_values) + end + def test_attribute_for_inspect_for_array_field record = PgArray.new { |a| a.ratings = (1..10).to_a } assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]", record.attribute_for_inspect(:ratings)) @@ -214,7 +257,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase x = PgArray.create!(tags: tags) x.reload - refute x.changed? + assert_not_predicate x, :changed? end def test_quoting_non_standard_delimiters @@ -236,7 +279,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase x.reload assert_equal %w(one two three), x.tags - assert_not x.changed? + assert_not_predicate x, :changed? end def test_mutate_value_in_array @@ -247,7 +290,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase x.reload assert_equal [{ "a" => "c" }, { "b" => "b" }], x.hstores - assert_not x.changed? + assert_not_predicate x, :changed? end def test_datetime_with_timezone_awareness @@ -310,7 +353,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase assert e1.persisted?, "Saving e1" e2 = klass.create("tags" => ["black", "blue"]) - assert !e2.persisted?, "e2 shouldn't be valid" + assert_not e2.persisted?, "e2 shouldn't be valid" assert e2.errors[:tags].any?, "Should have errors for tags" assert_equal ["has already been taken"], e2.errors[:tags], "Should have uniqueness message for tags" end diff --git a/activerecord/test/cases/adapters/postgresql/bit_string_test.rb b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb index 7712e809a2..c8e728bbb6 100644 --- a/activerecord/test/cases/adapters/postgresql/bit_string_test.rb +++ b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" require "support/schema_dumping_helper" @@ -27,20 +29,20 @@ class PostgresqlBitStringTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlBitString.columns_hash["a_bit"] assert_equal :bit, column.type assert_equal "bit(8)", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlBitString.type_for_attribute("a_bit") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_bit_string_varying_column column = PostgresqlBitString.columns_hash["a_bit_varying"] assert_equal :bit_varying, column.type assert_equal "bit varying(4)", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlBitString.type_for_attribute("a_bit_varying") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_default diff --git a/activerecord/test/cases/adapters/postgresql/bytea_test.rb b/activerecord/test/cases/adapters/postgresql/bytea_test.rb index 505c297cd4..64bb6906cd 100644 --- a/activerecord/test/cases/adapters/postgresql/bytea_test.rb +++ b/activerecord/test/cases/adapters/postgresql/bytea_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -47,7 +49,7 @@ class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase end def test_type_cast_binary_value - data = "\u001F\x8B".force_encoding("BINARY") + data = "\u001F\x8B".dup.force_encoding("BINARY") assert_equal(data, @type.deserialize(data)) end @@ -73,7 +75,7 @@ class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase def test_write_value data = "\u001F" record = ByteaDataType.create(payload: data) - assert_not record.new_record? + assert_not_predicate record, :new_record? assert_equal(data, record.payload) end @@ -89,23 +91,24 @@ class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase Thread.new do other_conn = ActiveRecord::Base.connection other_conn.execute("SET standard_conforming_strings = off") + other_conn.execute("SET escape_string_warning = off") end.join test_via_to_sql end def test_write_binary - data = File.read(File.join(File.dirname(__FILE__), "..", "..", "..", "assets", "example.log")) + data = File.read(File.join(__dir__, "..", "..", "..", "assets", "example.log")) assert(data.size > 1) record = ByteaDataType.create(payload: data) - assert_not record.new_record? + assert_not_predicate record, :new_record? assert_equal(data, record.payload) assert_equal(data, ByteaDataType.where(id: record.id).first.payload) end def test_write_nil record = ByteaDataType.create(payload: nil) - assert_not record.new_record? + assert_not_predicate record, :new_record? assert_nil(record.payload) assert_nil(ByteaDataType.where(id: record.id).first.payload) end diff --git a/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb index 03b44feab6..305e033642 100644 --- a/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb +++ b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlCaseInsensitiveTest < ActiveRecord::PostgreSQLTestCase diff --git a/activerecord/test/cases/adapters/postgresql/change_schema_test.rb b/activerecord/test/cases/adapters/postgresql/change_schema_test.rb index ea642069d2..6dba4f3e14 100644 --- a/activerecord/test/cases/adapters/postgresql/change_schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/change_schema_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -31,7 +33,7 @@ module ActiveRecord connection.change_column :strings, :somedate, :timestamp, array: true, cast_as: :timestamp column = connection.columns(:strings).find { |c| c.name == "somedate" } assert_equal :datetime, column.type - assert column.array? + assert_predicate column, :array? end end end diff --git a/activerecord/test/cases/adapters/postgresql/cidr_test.rb b/activerecord/test/cases/adapters/postgresql/cidr_test.rb index 52f2a0096c..f20958fbd2 100644 --- a/activerecord/test/cases/adapters/postgresql/cidr_test.rb +++ b/activerecord/test/cases/adapters/postgresql/cidr_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "ipaddr" diff --git a/activerecord/test/cases/adapters/postgresql/citext_test.rb b/activerecord/test/cases/adapters/postgresql/citext_test.rb index ca95e4b626..9eb0b7d99c 100644 --- a/activerecord/test/cases/adapters/postgresql/citext_test.rb +++ b/activerecord/test/cases/adapters/postgresql/citext_test.rb @@ -1,78 +1,78 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" -if ActiveRecord::Base.connection.supports_extensions? - class PostgresqlCitextTest < ActiveRecord::PostgreSQLTestCase - include SchemaDumpingHelper - class Citext < ActiveRecord::Base - self.table_name = "citexts" - end +class PostgresqlCitextTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class Citext < ActiveRecord::Base + self.table_name = "citexts" + end - def setup - @connection = ActiveRecord::Base.connection + def setup + @connection = ActiveRecord::Base.connection - enable_extension!("citext", @connection) + enable_extension!("citext", @connection) - @connection.create_table("citexts") do |t| - t.citext "cival" - end + @connection.create_table("citexts") do |t| + t.citext "cival" end + end - teardown do - @connection.drop_table "citexts", if_exists: true - disable_extension!("citext", @connection) - end + teardown do + @connection.drop_table "citexts", if_exists: true + disable_extension!("citext", @connection) + end - def test_citext_enabled - assert @connection.extension_enabled?("citext") - end + def test_citext_enabled + assert @connection.extension_enabled?("citext") + end - def test_column - column = Citext.columns_hash["cival"] - assert_equal :citext, column.type - assert_equal "citext", column.sql_type - assert_not column.array? + def test_column + column = Citext.columns_hash["cival"] + assert_equal :citext, column.type + assert_equal "citext", column.sql_type + assert_not_predicate column, :array? - type = Citext.type_for_attribute("cival") - assert_not type.binary? - end - - def test_change_table_supports_json - @connection.transaction do - @connection.change_table("citexts") do |t| - t.citext "username" - end - Citext.reset_column_information - column = Citext.columns_hash["username"] - assert_equal :citext, column.type + type = Citext.type_for_attribute("cival") + assert_not_predicate type, :binary? + end - raise ActiveRecord::Rollback # reset the schema change + def test_change_table_supports_json + @connection.transaction do + @connection.change_table("citexts") do |t| + t.citext "username" end - ensure Citext.reset_column_information + column = Citext.columns_hash["username"] + assert_equal :citext, column.type + + raise ActiveRecord::Rollback # reset the schema change end + ensure + Citext.reset_column_information + end - def test_write - x = Citext.new(cival: "Some CI Text") - x.save! - citext = Citext.first - assert_equal "Some CI Text", citext.cival + def test_write + x = Citext.new(cival: "Some CI Text") + x.save! + citext = Citext.first + assert_equal "Some CI Text", citext.cival - citext.cival = "Some NEW CI Text" - citext.save! + citext.cival = "Some NEW CI Text" + citext.save! - assert_equal "Some NEW CI Text", citext.reload.cival - end + assert_equal "Some NEW CI Text", citext.reload.cival + end - def test_select_case_insensitive - @connection.execute "insert into citexts (cival) values('Cased Text')" - x = Citext.where(cival: "cased text").first - assert_equal "Cased Text", x.cival - end + def test_select_case_insensitive + @connection.execute "insert into citexts (cival) values('Cased Text')" + x = Citext.where(cival: "cased text").first + assert_equal "Cased Text", x.cival + end - def test_schema_dump_with_shorthand - output = dump_table_schema("citexts") - assert_match %r[t\.citext "cival"], output - end + def test_schema_dump_with_shorthand + output = dump_table_schema("citexts") + assert_match %r[t\.citext "cival"], output end end diff --git a/activerecord/test/cases/adapters/postgresql/collation_test.rb b/activerecord/test/cases/adapters/postgresql/collation_test.rb index b39e298a5d..7468f4c4f8 100644 --- a/activerecord/test/cases/adapters/postgresql/collation_test.rb +++ b/activerecord/test/cases/adapters/postgresql/collation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -47,7 +49,7 @@ class PostgresqlCollationTest < ActiveRecord::PostgreSQLTestCase test "schema dump includes collation" do output = dump_table_schema("postgresql_collations") - assert_match %r{t.string\s+"string_c",\s+collation: "C"$}, output - assert_match %r{t.text\s+"text_posix",\s+collation: "POSIX"$}, output + assert_match %r{t\.string\s+"string_c",\s+collation: "C"$}, output + assert_match %r{t\.text\s+"text_posix",\s+collation: "POSIX"$}, output end end diff --git a/activerecord/test/cases/adapters/postgresql/composite_test.rb b/activerecord/test/cases/adapters/postgresql/composite_test.rb index 1da2a9e2ac..b0ce2694a3 100644 --- a/activerecord/test/cases/adapters/postgresql/composite_test.rb +++ b/activerecord/test/cases/adapters/postgresql/composite_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -49,10 +51,10 @@ class PostgresqlCompositeTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlComposite.columns_hash["address"] assert_nil column.type assert_equal "full_address", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlComposite.type_for_attribute("address") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_composite_mapping @@ -104,17 +106,17 @@ class PostgresqlCompositeWithCustomOIDTest < ActiveRecord::PostgreSQLTestCase def setup super - @connection.type_map.register_type "full_address", FullAddressType.new + @connection.send(:type_map).register_type "full_address", FullAddressType.new end def test_column column = PostgresqlComposite.columns_hash["address"] assert_equal :full_address, column.type assert_equal "full_address", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlComposite.type_for_attribute("address") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_composite_mapping diff --git a/activerecord/test/cases/adapters/postgresql/connection_test.rb b/activerecord/test/cases/adapters/postgresql/connection_test.rb index c52d9e37cc..70aa189893 100644 --- a/activerecord/test/cases/adapters/postgresql/connection_test.rb +++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -13,8 +15,9 @@ module ActiveRecord def setup super @subscriber = SQLSubscriber.new - @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber) @connection = ActiveRecord::Base.connection + @connection.materialize_transactions + @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber) end def teardown @@ -31,15 +34,21 @@ module ActiveRecord end def test_encoding - assert_not_nil @connection.encoding + assert_queries(1) do + assert_not_nil @connection.encoding + end end def test_collation - assert_not_nil @connection.collation + assert_queries(1) do + assert_not_nil @connection.collation + end end def test_ctype - assert_not_nil @connection.ctype + assert_queries(1) do + assert_not_nil @connection.ctype + end end def test_default_client_min_messages @@ -95,7 +104,7 @@ module ActiveRecord end def test_indexes_logs_name - assert_deprecated { @connection.indexes("items", "hello") } + @connection.indexes("items") assert_equal "SCHEMA", @subscriber.logged[0][1] end @@ -143,13 +152,13 @@ module ActiveRecord # When prompted, restart the PostgreSQL server with the # "-m fast" option or kill the individual connection assuming # you know the incantation to do that. - # To restart PostgreSQL 9.1 on OS X, installed via MacPorts, ... + # To restart PostgreSQL 9.1 on macOS, installed via MacPorts, ... # sudo su postgres -c "pg_ctl restart -D /opt/local/var/db/postgresql91/defaultdb/ -m fast" def test_reconnection_after_actual_disconnection_with_verify original_connection_pid = @connection.query("select pg_backend_pid()") # Sanity check. - assert @connection.active? + assert_predicate @connection, :active? if @connection.send(:postgresql_version) >= 90200 secondary_connection = ActiveRecord::Base.connection_pool.checkout @@ -168,7 +177,7 @@ module ActiveRecord @connection.verify! - assert @connection.active? + assert_predicate @connection, :active? # If we get no exception here, then either we re-connected successfully, or # we never actually got disconnected. @@ -212,6 +221,13 @@ module ActiveRecord end end + def test_set_session_timezone + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { timezone: "America/New_York" })) + assert_equal "America/New_York", ActiveRecord::Base.connection.query_value("SHOW TIME ZONE") + end + end + def test_get_and_release_advisory_lock lock_id = 5295901941911233559 list_advisory_locks = <<-SQL diff --git a/activerecord/test/cases/adapters/postgresql/datatype_test.rb b/activerecord/test/cases/adapters/postgresql/datatype_test.rb index 0725fde5ae..b7535d5c9a 100644 --- a/activerecord/test/cases/adapters/postgresql/datatype_test.rb +++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/ddl_helper" diff --git a/activerecord/test/cases/adapters/postgresql/date_test.rb b/activerecord/test/cases/adapters/postgresql/date_test.rb new file mode 100644 index 0000000000..a86abac2be --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/date_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +class PostgresqlDateTest < ActiveRecord::PostgreSQLTestCase + def test_load_infinity_and_beyond + topic = Topic.find_by_sql("SELECT 'infinity'::date AS last_read").first + assert topic.last_read.infinite?, "timestamp should be infinite" + assert_operator topic.last_read, :>, 0 + + topic = Topic.find_by_sql("SELECT '-infinity'::date AS last_read").first + assert topic.last_read.infinite?, "timestamp should be infinite" + assert_operator topic.last_read, :<, 0 + end + + def test_save_infinity_and_beyond + topic = Topic.create!(last_read: 1.0 / 0.0) + assert_equal(1.0 / 0.0, topic.last_read) + + topic = Topic.create!(last_read: -1.0 / 0.0) + assert_equal(-1.0 / 0.0, topic.last_read) + end + + def test_bc_date + date = Date.new(0) - 1.week + topic = Topic.create!(last_read: date) + assert_equal date, Topic.find(topic.id).last_read + end + + def test_bc_date_leap_year + date = Time.utc(-4, 2, 29).to_date + topic = Topic.create!(last_read: date) + assert_equal date, Topic.find(topic.id).last_read + end + + def test_bc_date_year_zero + date = Time.utc(0, 4, 7).to_date + topic = Topic.create!(last_read: date) + assert_equal date, Topic.find(topic.id).last_read + end +end diff --git a/activerecord/test/cases/adapters/postgresql/domain_test.rb b/activerecord/test/cases/adapters/postgresql/domain_test.rb index f1eb8adb15..eeaad94c27 100644 --- a/activerecord/test/cases/adapters/postgresql/domain_test.rb +++ b/activerecord/test/cases/adapters/postgresql/domain_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -28,10 +30,10 @@ class PostgresqlDomainTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlDomain.columns_hash["price"] assert_equal :decimal, column.type assert_equal "custom_money", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlDomain.type_for_attribute("price") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_domain_acts_like_basetype @@ -42,6 +44,6 @@ class PostgresqlDomainTest < ActiveRecord::PostgreSQLTestCase record.price = "34.15" record.save! - assert_equal BigDecimal.new("34.15"), record.reload.price + assert_equal BigDecimal("34.15"), record.reload.price end end diff --git a/activerecord/test/cases/adapters/postgresql/enum_test.rb b/activerecord/test/cases/adapters/postgresql/enum_test.rb index 5e5a3158ba..6789ff63e7 100644 --- a/activerecord/test/cases/adapters/postgresql/enum_test.rb +++ b/activerecord/test/cases/adapters/postgresql/enum_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -30,10 +32,10 @@ class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlEnum.columns_hash["current_mood"] assert_equal :enum, column.type assert_equal "mood", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlEnum.type_for_attribute("current_mood") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_enum_defaults @@ -71,7 +73,7 @@ class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');" stderr_output = capture(:stderr) { PostgresqlEnum.first } - assert stderr_output.blank? + assert_predicate stderr_output, :blank? end def test_enum_type_cast diff --git a/activerecord/test/cases/adapters/postgresql/explain_test.rb b/activerecord/test/cases/adapters/postgresql/explain_test.rb index 7493bce4fb..be525383e9 100644 --- a/activerecord/test/cases/adapters/postgresql/explain_test.rb +++ b/activerecord/test/cases/adapters/postgresql/explain_test.rb @@ -1,20 +1,22 @@ +# frozen_string_literal: true + require "cases/helper" -require "models/developer" -require "models/computer" +require "models/author" +require "models/post" class PostgreSQLExplainTest < ActiveRecord::PostgreSQLTestCase - fixtures :developers + fixtures :authors def test_explain_for_one_query - explain = Developer.where(id: 1).explain - assert_match %r(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = (?:\$1 \[\["id", 1\]\]|1)), explain + explain = Author.where(id: 1).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain assert_match %(QUERY PLAN), explain end def test_explain_with_eager_loading - explain = Developer.where(id: 1).includes(:audit_logs).explain + explain = Author.where(id: 1).includes(:posts).explain assert_match %(QUERY PLAN), explain - assert_match %r(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = (?:\$1 \[\["id", 1\]\]|1)), explain - assert_match %(EXPLAIN for: SELECT "audit_logs".* FROM "audit_logs" WHERE "audit_logs"."developer_id" = 1), explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain + assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\$1 \[\["author_id", 1\]\]|1)), explain 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 b56c226763..df97ab11e7 100644 --- a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb +++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase @@ -20,10 +22,6 @@ class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase @connection = ActiveRecord::Base.connection - unless @connection.supports_extensions? - return skip("no extension support") - end - @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 diff --git a/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb new file mode 100644 index 0000000000..4fa315ad23 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/professor" + +if ActiveRecord::Base.connection.supports_foreign_tables? + class ForeignTableTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class ForeignProfessor < ActiveRecord::Base + self.table_name = "foreign_professors" + end + + class ForeignProfessorWithPk < ForeignProfessor + self.primary_key = "id" + end + + def setup + @professor = Professor.create(name: "Nicola") + + @connection = ActiveRecord::Base.connection + enable_extension!("postgres_fdw", @connection) + + foreign_db_config = ARTest.connection_config["arunit2"] + @connection.execute <<-SQL + CREATE SERVER foreign_server + FOREIGN DATA WRAPPER postgres_fdw + OPTIONS (dbname '#{foreign_db_config["database"]}') + SQL + + @connection.execute <<-SQL + CREATE USER MAPPING FOR CURRENT_USER + SERVER foreign_server + SQL + + @connection.execute <<-SQL + CREATE FOREIGN TABLE foreign_professors ( + id int, + name character varying NOT NULL + ) SERVER foreign_server OPTIONS ( + table_name 'professors' + ) + SQL + end + + def teardown + disable_extension!("postgres_fdw", @connection) + @connection.execute <<-SQL + DROP SERVER IF EXISTS foreign_server CASCADE + SQL + end + + def test_table_exists + table_name = ForeignProfessor.table_name + assert_not ActiveRecord::Base.connection.table_exists?(table_name) + end + + def test_foreign_tables_are_valid_data_sources + table_name = ForeignProfessor.table_name + assert @connection.data_source_exists?(table_name), "'#{table_name}' should be a data source" + end + + def test_foreign_tables + assert_equal ["foreign_professors"], @connection.foreign_tables + end + + def test_foreign_table_exists + assert @connection.foreign_table_exists?("foreign_professors") + assert @connection.foreign_table_exists?(:foreign_professors) + assert_not @connection.foreign_table_exists?("nonexistingtable") + assert_not @connection.foreign_table_exists?("'") + assert_not @connection.foreign_table_exists?(nil) + end + + def test_attribute_names + assert_equal ["id", "name"], ForeignProfessor.attribute_names + end + + def test_attributes + professor = ForeignProfessorWithPk.find(@professor.id) + assert_equal @professor.attributes, professor.attributes + end + + def test_does_not_have_a_primary_key + assert_nil ForeignProfessor.primary_key + end + + def test_insert_record + # Explicit `id` here to avoid complex configurations to implicitly work with remote table + ForeignProfessorWithPk.create!(id: 100, name: "Leonardo") + + professor = ForeignProfessorWithPk.last + assert_equal "Leonardo", professor.name + end + + def test_update_record + professor = ForeignProfessorWithPk.find(@professor.id) + professor.name = "Albert" + professor.save! + professor.reload + assert_equal "Albert", professor.name + end + + def test_delete_record + professor = ForeignProfessorWithPk.find(@professor.id) + assert_difference("ForeignProfessor.count", -1) { professor.destroy } + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/full_text_test.rb b/activerecord/test/cases/adapters/postgresql/full_text_test.rb index 5ddfe32007..95dee3bf44 100644 --- a/activerecord/test/cases/adapters/postgresql/full_text_test.rb +++ b/activerecord/test/cases/adapters/postgresql/full_text_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -20,10 +22,10 @@ class PostgresqlFullTextTest < ActiveRecord::PostgreSQLTestCase column = Tsvector.columns_hash["text_vector"] assert_equal :tsvector, column.type assert_equal "tsvector", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = Tsvector.type_for_attribute("text_vector") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_update_tsvector diff --git a/activerecord/test/cases/adapters/postgresql/geometric_test.rb b/activerecord/test/cases/adapters/postgresql/geometric_test.rb index c1f3a4ae2c..8c6f046553 100644 --- a/activerecord/test/cases/adapters/postgresql/geometric_test.rb +++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" require "support/schema_dumping_helper" @@ -37,10 +39,10 @@ class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlPoint.columns_hash["x"] assert_equal :point, column.type assert_equal "point", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlPoint.type_for_attribute("x") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_default @@ -77,7 +79,7 @@ class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase p.reload assert_equal ActiveRecord::Point.new(10.0, 25.0), p.x - assert_not p.changed? + assert_not_predicate p, :changed? end def test_array_assignment @@ -93,8 +95,6 @@ class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase end def test_empty_string_assignment - assert_nothing_raised { PostgresqlPoint.new(x: "") } - p = PostgresqlPoint.new(x: "") assert_nil p.x end @@ -117,10 +117,10 @@ class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlPoint.columns_hash["legacy_x"] assert_equal :point, column.type assert_equal "point", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlPoint.type_for_attribute("legacy_x") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_legacy_default @@ -157,7 +157,7 @@ class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase p.reload assert_equal [10.0, 25.0], p.legacy_x - assert_not p.changed? + assert_not_predicate p, :changed? end end diff --git a/activerecord/test/cases/adapters/postgresql/hstore_test.rb b/activerecord/test/cases/adapters/postgresql/hstore_test.rb index f9cce10fb8..4b061a9375 100644 --- a/activerecord/test/cases/adapters/postgresql/hstore_test.rb +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -1,382 +1,378 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" -if ActiveRecord::Base.connection.supports_extensions? - class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase - include SchemaDumpingHelper - class Hstore < ActiveRecord::Base - self.table_name = "hstores" +class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class Hstore < ActiveRecord::Base + self.table_name = "hstores" - store_accessor :settings, :language, :timezone - end + store_accessor :settings, :language, :timezone + end - class FakeParameters - def to_unsafe_h - { "hi" => "hi" } - end + class FakeParameters + def to_unsafe_h + { "hi" => "hi" } end + end - def setup - @connection = ActiveRecord::Base.connection - - unless @connection.extension_enabled?("hstore") - @connection.enable_extension "hstore" - @connection.commit_db_transaction - end + def setup + @connection = ActiveRecord::Base.connection - @connection.reconnect! + enable_extension!("hstore", @connection) - @connection.transaction do - @connection.create_table("hstores") do |t| - t.hstore "tags", default: "" - t.hstore "payload", array: true - t.hstore "settings" - end + @connection.transaction do + @connection.create_table("hstores") do |t| + t.hstore "tags", default: "" + t.hstore "payload", array: true + t.hstore "settings" end - Hstore.reset_column_information - @column = Hstore.columns_hash["tags"] - @type = Hstore.type_for_attribute("tags") - end - - teardown do - @connection.drop_table "hstores", if_exists: true end + Hstore.reset_column_information + @column = Hstore.columns_hash["tags"] + @type = Hstore.type_for_attribute("tags") + end - def test_hstore_included_in_extensions - assert @connection.respond_to?(:extensions), "connection should have a list of extensions" - assert_includes @connection.extensions, "hstore", "extension list should include hstore" - end + teardown do + @connection.drop_table "hstores", if_exists: true + disable_extension!("hstore", @connection) + end - def test_disable_enable_hstore - assert @connection.extension_enabled?("hstore") - @connection.disable_extension "hstore" - assert_not @connection.extension_enabled?("hstore") - @connection.enable_extension "hstore" - assert @connection.extension_enabled?("hstore") - ensure - # Restore column(s) dropped by `drop extension hstore cascade;` - load_schema - end + def test_hstore_included_in_extensions + assert_respond_to @connection, :extensions + assert_includes @connection.extensions, "hstore", "extension list should include hstore" + end - def test_column - assert_equal :hstore, @column.type - assert_equal "hstore", @column.sql_type - assert_not @column.array? + def test_disable_enable_hstore + assert @connection.extension_enabled?("hstore") + @connection.disable_extension "hstore" + assert_not @connection.extension_enabled?("hstore") + @connection.enable_extension "hstore" + assert @connection.extension_enabled?("hstore") + ensure + # Restore column(s) dropped by `drop extension hstore cascade;` + load_schema + end - assert_not @type.binary? - end + def test_column + assert_equal :hstore, @column.type + assert_equal "hstore", @column.sql_type + assert_not_predicate @column, :array? - def test_default - @connection.add_column "hstores", "permissions", :hstore, default: '"users"=>"read", "articles"=>"write"' - Hstore.reset_column_information + assert_not_predicate @type, :binary? + end - assert_equal({ "users" => "read", "articles" => "write" }, Hstore.column_defaults["permissions"]) - assert_equal({ "users" => "read", "articles" => "write" }, Hstore.new.permissions) - ensure - Hstore.reset_column_information - end + def test_default + @connection.add_column "hstores", "permissions", :hstore, default: '"users"=>"read", "articles"=>"write"' + Hstore.reset_column_information - def test_change_table_supports_hstore - @connection.transaction do - @connection.change_table("hstores") do |t| - t.hstore "users", default: "" - end - Hstore.reset_column_information - column = Hstore.columns_hash["users"] - assert_equal :hstore, column.type + assert_equal({ "users" => "read", "articles" => "write" }, Hstore.column_defaults["permissions"]) + assert_equal({ "users" => "read", "articles" => "write" }, Hstore.new.permissions) + ensure + Hstore.reset_column_information + end - raise ActiveRecord::Rollback # reset the schema change + def test_change_table_supports_hstore + @connection.transaction do + @connection.change_table("hstores") do |t| + t.hstore "users", default: "" end - ensure Hstore.reset_column_information + column = Hstore.columns_hash["users"] + assert_equal :hstore, column.type + + raise ActiveRecord::Rollback # reset the schema change end + ensure + Hstore.reset_column_information + end - def test_hstore_migration - hstore_migration = Class.new(ActiveRecord::Migration::Current) do - def change - change_table("hstores") do |t| - t.hstore :keys - end + def test_hstore_migration + hstore_migration = Class.new(ActiveRecord::Migration::Current) do + def change + change_table("hstores") do |t| + t.hstore :keys end end - - hstore_migration.new.suppress_messages do - hstore_migration.migrate(:up) - assert_includes @connection.columns(:hstores).map(&:name), "keys" - hstore_migration.migrate(:down) - assert_not_includes @connection.columns(:hstores).map(&:name), "keys" - end end - def test_cast_value_on_write - x = Hstore.new tags: { "bool" => true, "number" => 5 } - assert_equal({ "bool" => true, "number" => 5 }, x.tags_before_type_cast) - assert_equal({ "bool" => "true", "number" => "5" }, x.tags) - x.save - assert_equal({ "bool" => "true", "number" => "5" }, x.reload.tags) + hstore_migration.new.suppress_messages do + hstore_migration.migrate(:up) + assert_includes @connection.columns(:hstores).map(&:name), "keys" + hstore_migration.migrate(:down) + assert_not_includes @connection.columns(:hstores).map(&:name), "keys" end + end - def test_type_cast_hstore - assert_equal({ "1" => "2" }, @type.deserialize("\"1\"=>\"2\"")) - assert_equal({}, @type.deserialize("")) - assert_equal({ "key" => nil }, @type.deserialize("key => NULL")) - assert_equal({ "c" => "}", '"a"' => 'b "a b' }, @type.deserialize(%q(c=>"}", "\"a\""=>"b \"a b"))) - end + def test_cast_value_on_write + x = Hstore.new tags: { "bool" => true, "number" => 5 } + assert_equal({ "bool" => true, "number" => 5 }, x.tags_before_type_cast) + assert_equal({ "bool" => "true", "number" => "5" }, x.tags) + x.save + assert_equal({ "bool" => "true", "number" => "5" }, x.reload.tags) + end - def test_with_store_accessors - x = Hstore.new(language: "fr", timezone: "GMT") - assert_equal "fr", x.language - assert_equal "GMT", x.timezone + def test_type_cast_hstore + assert_equal({ "1" => "2" }, @type.deserialize("\"1\"=>\"2\"")) + assert_equal({}, @type.deserialize("")) + assert_equal({ "key" => nil }, @type.deserialize("key => NULL")) + assert_equal({ "c" => "}", '"a"' => 'b "a b' }, @type.deserialize(%q(c=>"}", "\"a\""=>"b \"a b"))) + end - x.save! - x = Hstore.first - assert_equal "fr", x.language - assert_equal "GMT", x.timezone + def test_with_store_accessors + x = Hstore.new(language: "fr", timezone: "GMT") + assert_equal "fr", x.language + assert_equal "GMT", x.timezone - x.language = "de" - x.save! + x.save! + x = Hstore.first + assert_equal "fr", x.language + assert_equal "GMT", x.timezone - x = Hstore.first - assert_equal "de", x.language - assert_equal "GMT", x.timezone - end + x.language = "de" + x.save! - def test_duplication_with_store_accessors - x = Hstore.new(language: "fr", timezone: "GMT") - assert_equal "fr", x.language - assert_equal "GMT", x.timezone + x = Hstore.first + assert_equal "de", x.language + assert_equal "GMT", x.timezone + end - y = x.dup - assert_equal "fr", y.language - assert_equal "GMT", y.timezone - end + def test_duplication_with_store_accessors + x = Hstore.new(language: "fr", timezone: "GMT") + assert_equal "fr", x.language + assert_equal "GMT", x.timezone - def test_yaml_round_trip_with_store_accessors - x = Hstore.new(language: "fr", timezone: "GMT") - assert_equal "fr", x.language - assert_equal "GMT", x.timezone + y = x.dup + assert_equal "fr", y.language + assert_equal "GMT", y.timezone + end - y = YAML.load(YAML.dump(x)) - assert_equal "fr", y.language - assert_equal "GMT", y.timezone - end + def test_yaml_round_trip_with_store_accessors + x = Hstore.new(language: "fr", timezone: "GMT") + assert_equal "fr", x.language + assert_equal "GMT", x.timezone - def test_changes_in_place - hstore = Hstore.create!(settings: { "one" => "two" }) - hstore.settings["three"] = "four" - hstore.save! - hstore.reload + y = YAML.load(YAML.dump(x)) + assert_equal "fr", y.language + assert_equal "GMT", y.timezone + end - assert_equal "four", hstore.settings["three"] - assert_not hstore.changed? - end + def test_changes_in_place + hstore = Hstore.create!(settings: { "one" => "two" }) + hstore.settings["three"] = "four" + hstore.save! + hstore.reload - def test_dirty_from_user_equal - settings = { "alongkey" => "anything", "key" => "value" } - hstore = Hstore.create!(settings: settings) + assert_equal "four", hstore.settings["three"] + assert_not_predicate hstore, :changed? + end - hstore.settings = { "key" => "value", "alongkey" => "anything" } - assert_equal settings, hstore.settings - refute hstore.changed? - end + def test_dirty_from_user_equal + settings = { "alongkey" => "anything", "key" => "value" } + hstore = Hstore.create!(settings: settings) - def test_hstore_dirty_from_database_equal - settings = { "alongkey" => "anything", "key" => "value" } - hstore = Hstore.create!(settings: settings) - hstore.reload + hstore.settings = { "key" => "value", "alongkey" => "anything" } + assert_equal settings, hstore.settings + assert_not_predicate hstore, :changed? + end - assert_equal settings, hstore.settings - hstore.settings = settings - refute hstore.changed? - end + def test_hstore_dirty_from_database_equal + settings = { "alongkey" => "anything", "key" => "value" } + hstore = Hstore.create!(settings: settings) + hstore.reload - def test_gen1 - assert_equal('" "=>""', @type.serialize(" " => "")) - end + assert_equal settings, hstore.settings + hstore.settings = settings + assert_not_predicate hstore, :changed? + end - def test_gen2 - assert_equal('","=>""', @type.serialize("," => "")) - end + def test_gen1 + assert_equal('" "=>""', @type.serialize(" " => "")) + end - def test_gen3 - assert_equal('"="=>""', @type.serialize("=" => "")) - end + def test_gen2 + assert_equal('","=>""', @type.serialize("," => "")) + end - def test_gen4 - assert_equal('">"=>""', @type.serialize(">" => "")) - end + def test_gen3 + assert_equal('"="=>""', @type.serialize("=" => "")) + end - def test_parse1 - assert_equal({ "a" => nil, "b" => nil, "c" => "NuLl", "null" => "c" }, @type.deserialize('a=>null,b=>NuLl,c=>"NuLl",null=>c')) - end + def test_gen4 + assert_equal('">"=>""', @type.serialize(">" => "")) + end - def test_parse2 - assert_equal({ " " => " " }, @type.deserialize("\\ =>\\ ")) - end + def test_parse1 + assert_equal({ "a" => nil, "b" => nil, "c" => "NuLl", "null" => "c" }, @type.deserialize('a=>null,b=>NuLl,c=>"NuLl",null=>c')) + end - def test_parse3 - assert_equal({ "=" => ">" }, @type.deserialize("==>>")) - end + def test_parse2 + assert_equal({ " " => " " }, @type.deserialize("\\ =>\\ ")) + end - def test_parse4 - assert_equal({ "=a" => "q=w" }, @type.deserialize('\=a=>q=w')) - end + def test_parse3 + assert_equal({ "=" => ">" }, @type.deserialize("==>>")) + end - def test_parse5 - assert_equal({ "=a" => "q=w" }, @type.deserialize('"=a"=>q\=w')) - end + def test_parse4 + assert_equal({ "=a" => "q=w" }, @type.deserialize('\=a=>q=w')) + end - def test_parse6 - assert_equal({ "\"a" => "q>w" }, @type.deserialize('"\"a"=>q>w')) - end + def test_parse5 + assert_equal({ "=a" => "q=w" }, @type.deserialize('"=a"=>q\=w')) + end - def test_parse7 - assert_equal({ "\"a" => "q\"w" }, @type.deserialize('\"a=>q"w')) - end + def test_parse6 + assert_equal({ "\"a" => "q>w" }, @type.deserialize('"\"a"=>q>w')) + end - def test_rewrite - @connection.execute "insert into hstores (tags) VALUES ('1=>2')" - x = Hstore.first - x.tags = { '"a\'' => "b" } - assert x.save! - end + def test_parse7 + assert_equal({ "\"a" => "q\"w" }, @type.deserialize('\"a=>q"w')) + end - def test_select - @connection.execute "insert into hstores (tags) VALUES ('1=>2')" - x = Hstore.first - assert_equal({ "1" => "2" }, x.tags) - end + def test_rewrite + @connection.execute "insert into hstores (tags) VALUES ('1=>2')" + x = Hstore.first + x.tags = { '"a\'' => "b" } + assert x.save! + end - def test_array_cycle - assert_array_cycle([{ "AA" => "BB", "CC" => "DD" }, { "AA" => nil }]) - end + def test_select + @connection.execute "insert into hstores (tags) VALUES ('1=>2')" + x = Hstore.first + assert_equal({ "1" => "2" }, x.tags) + end - def test_array_strings_with_quotes - assert_array_cycle([{ "this has" => 'some "s that need to be escaped"' }]) - end + def test_array_cycle + assert_array_cycle([{ "AA" => "BB", "CC" => "DD" }, { "AA" => nil }]) + end - def test_array_strings_with_commas - assert_array_cycle([{ "this,has" => "many,values" }]) - end + def test_array_strings_with_quotes + assert_array_cycle([{ "this has" => 'some "s that need to be escaped"' }]) + end - def test_array_strings_with_array_delimiters - assert_array_cycle(["{" => "}"]) - end + def test_array_strings_with_commas + assert_array_cycle([{ "this,has" => "many,values" }]) + end - def test_array_strings_with_null_strings - assert_array_cycle([{ "NULL" => "NULL" }]) - end + def test_array_strings_with_array_delimiters + assert_array_cycle(["{" => "}"]) + end - def test_contains_nils - assert_array_cycle([{ "NULL" => nil }]) - end + def test_array_strings_with_null_strings + assert_array_cycle([{ "NULL" => "NULL" }]) + end - def test_select_multikey - @connection.execute "insert into hstores (tags) VALUES ('1=>2,2=>3')" - x = Hstore.first - assert_equal({ "1" => "2", "2" => "3" }, x.tags) - end + def test_contains_nils + assert_array_cycle([{ "NULL" => nil }]) + end - def test_create - assert_cycle("a" => "b", "1" => "2") - end + def test_select_multikey + @connection.execute "insert into hstores (tags) VALUES ('1=>2,2=>3')" + x = Hstore.first + assert_equal({ "1" => "2", "2" => "3" }, x.tags) + end - def test_nil - assert_cycle("a" => nil) - end + def test_create + assert_cycle("a" => "b", "1" => "2") + end - def test_quotes - assert_cycle("a" => 'b"ar', '1"foo' => "2") - end + def test_nil + assert_cycle("a" => nil) + end - def test_whitespace - assert_cycle("a b" => "b ar", '1"foo' => "2") - end + def test_quotes + assert_cycle("a" => 'b"ar', '1"foo' => "2") + end - def test_backslash - assert_cycle('a\\b' => 'b\\ar', '1"foo' => "2") - end + def test_whitespace + assert_cycle("a b" => "b ar", '1"foo' => "2") + end - def test_comma - assert_cycle("a, b" => "bar", '1"foo' => "2") - end + def test_backslash + assert_cycle('a\\b' => 'b\\ar', '1"foo' => "2") + end - def test_arrow - assert_cycle("a=>b" => "bar", '1"foo' => "2") - end + def test_comma + assert_cycle("a, b" => "bar", '1"foo' => "2") + end - def test_quoting_special_characters - assert_cycle("ca" => "cà", "ac" => "àc") - end + def test_arrow + assert_cycle("a=>b" => "bar", '1"foo' => "2") + end - def test_multiline - assert_cycle("a\nb" => "c\nd") - end + def test_quoting_special_characters + assert_cycle("ca" => "cà", "ac" => "àc") + end - class TagCollection - def initialize(hash); @hash = hash end - def to_hash; @hash end - def self.load(hash); new(hash) end - def self.dump(object); object.to_hash end - end + def test_multiline + assert_cycle("a\nb" => "c\nd") + end - class HstoreWithSerialize < Hstore - serialize :tags, TagCollection - end + class TagCollection + def initialize(hash); @hash = hash end + def to_hash; @hash end + def self.load(hash); new(hash) end + def self.dump(object); object.to_hash end + end - def test_hstore_with_serialized_attributes - HstoreWithSerialize.create! tags: TagCollection.new("one" => "two") - record = HstoreWithSerialize.first - assert_instance_of TagCollection, record.tags - assert_equal({ "one" => "two" }, record.tags.to_hash) - record.tags = TagCollection.new("three" => "four") - record.save! - assert_equal({ "three" => "four" }, HstoreWithSerialize.first.tags.to_hash) - end + class HstoreWithSerialize < Hstore + serialize :tags, TagCollection + end - def test_clone_hstore_with_serialized_attributes - HstoreWithSerialize.create! tags: TagCollection.new("one" => "two") - record = HstoreWithSerialize.first - dupe = record.dup - assert_equal({ "one" => "two" }, dupe.tags.to_hash) - end + def test_hstore_with_serialized_attributes + HstoreWithSerialize.create! tags: TagCollection.new("one" => "two") + record = HstoreWithSerialize.first + assert_instance_of TagCollection, record.tags + assert_equal({ "one" => "two" }, record.tags.to_hash) + record.tags = TagCollection.new("three" => "four") + record.save! + assert_equal({ "three" => "four" }, HstoreWithSerialize.first.tags.to_hash) + end - def test_schema_dump_with_shorthand - output = dump_table_schema("hstores") - assert_match %r[t\.hstore "tags",\s+default: {}], output - end + def test_clone_hstore_with_serialized_attributes + HstoreWithSerialize.create! tags: TagCollection.new("one" => "two") + record = HstoreWithSerialize.first + dupe = record.dup + assert_equal({ "one" => "two" }, dupe.tags.to_hash) + end + + def test_schema_dump_with_shorthand + output = dump_table_schema("hstores") + assert_match %r[t\.hstore "tags",\s+default: {}], output + end + + def test_supports_to_unsafe_h_values + assert_equal("\"hi\"=>\"hi\"", @type.serialize(FakeParameters.new)) + end - def test_supports_to_unsafe_h_values - assert_equal("\"hi\"=>\"hi\"", @type.serialize(FakeParameters.new)) + private + def assert_array_cycle(array) + # test creation + x = Hstore.create!(payload: array) + x.reload + assert_equal(array, x.payload) + + # test updating + x = Hstore.create!(payload: []) + x.payload = array + x.save! + x.reload + assert_equal(array, x.payload) end - private - def assert_array_cycle(array) - # test creation - x = Hstore.create!(payload: array) - x.reload - assert_equal(array, x.payload) - - # test updating - x = Hstore.create!(payload: []) - x.payload = array - x.save! - x.reload - assert_equal(array, x.payload) - end + def assert_cycle(hash) + # test creation + x = Hstore.create!(tags: hash) + x.reload + assert_equal(hash, x.tags) - def assert_cycle(hash) - # test creation - x = Hstore.create!(tags: hash) - x.reload - assert_equal(hash, x.tags) - - # test updating - x = Hstore.create!(tags: {}) - x.tags = hash - x.save! - x.reload - assert_equal(hash, x.tags) - end - end + # test updating + x = Hstore.create!(tags: {}) + x.tags = hash + x.save! + x.reload + assert_equal(hash, x.tags) + end end diff --git a/activerecord/test/cases/adapters/postgresql/infinity_test.rb b/activerecord/test/cases/adapters/postgresql/infinity_test.rb index b9e177e6ec..5e56ce8427 100644 --- a/activerecord/test/cases/adapters/postgresql/infinity_test.rb +++ b/activerecord/test/cases/adapters/postgresql/infinity_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase @@ -11,6 +13,7 @@ class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase @connection.create_table(:postgresql_infinities) do |t| t.float :float t.datetime :datetime + t.date :date end end @@ -41,11 +44,25 @@ class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase end test "type casting infinity on a datetime column" do + record = PostgresqlInfinity.create!(datetime: "infinity") + record.reload + assert_equal Float::INFINITY, record.datetime + record = PostgresqlInfinity.create!(datetime: Float::INFINITY) record.reload assert_equal Float::INFINITY, record.datetime end + test "type casting infinity on a date column" do + record = PostgresqlInfinity.create!(date: "infinity") + record.reload + assert_equal Float::INFINITY, record.date + + record = PostgresqlInfinity.create!(date: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.date + end + test "update_all with infinity on a datetime column" do record = PostgresqlInfinity.create! PostgresqlInfinity.update_all(datetime: Float::INFINITY) @@ -66,4 +83,28 @@ class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase PostgresqlInfinity.reset_column_information end end + + test "where clause with infinite range on a datetime column" do + record = PostgresqlInfinity.create!(datetime: Time.current) + + string = PostgresqlInfinity.where(datetime: "-infinity".."infinity") + assert_equal record, string.take + + infinity = PostgresqlInfinity.where(datetime: -::Float::INFINITY..::Float::INFINITY) + assert_equal record, infinity.take + + assert_equal infinity.to_sql, string.to_sql + end + + test "where clause with infinite range on a date column" do + record = PostgresqlInfinity.create!(date: Date.current) + + string = PostgresqlInfinity.where(date: "-infinity".."infinity") + assert_equal record, string.take + + infinity = PostgresqlInfinity.where(date: -::Float::INFINITY..::Float::INFINITY) + assert_equal record, infinity.take + + assert_equal infinity.to_sql, string.to_sql + end end diff --git a/activerecord/test/cases/adapters/postgresql/integer_test.rb b/activerecord/test/cases/adapters/postgresql/integer_test.rb index b4e55964b9..3e45b057ff 100644 --- a/activerecord/test/cases/adapters/postgresql/integer_test.rb +++ b/activerecord/test/cases/adapters/postgresql/integer_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_support/core_ext/numeric/bytes" diff --git a/activerecord/test/cases/adapters/postgresql/json_test.rb b/activerecord/test/cases/adapters/postgresql/json_test.rb index 93558ac4d2..ee08841eb3 100644 --- a/activerecord/test/cases/adapters/postgresql/json_test.rb +++ b/activerecord/test/cases/adapters/postgresql/json_test.rb @@ -1,212 +1,37 @@ +# frozen_string_literal: true + require "cases/helper" -require "support/schema_dumping_helper" +require "cases/json_shared_test_cases" module PostgresqlJSONSharedTestCases - include SchemaDumpingHelper - - class JsonDataType < ActiveRecord::Base - self.table_name = "json_data_type" - - store_accessor :settings, :resolution - end + include JSONSharedTestCases def setup - @connection = ActiveRecord::Base.connection - begin - @connection.create_table("json_data_type") do |t| - t.public_send column_type, "payload", default: {} # t.json 'payload', default: {} - t.public_send column_type, "settings" # t.json 'settings' - end - rescue ActiveRecord::StatementInvalid - skip "do not test on PostgreSQL without #{column_type} type." + super + @connection.create_table("json_data_type") do |t| + t.public_send column_type, "payload", default: {} # t.json 'payload', default: {} + t.public_send column_type, "settings" # t.json 'settings' + t.public_send column_type, "objects", array: true # t.json 'objects', array: true end - end - - def teardown - @connection.drop_table :json_data_type, if_exists: true - JsonDataType.reset_column_information - end - - def test_column - column = JsonDataType.columns_hash["payload"] - assert_equal column_type, column.type - assert_equal column_type.to_s, column.sql_type - assert_not column.array? - - type = JsonDataType.type_for_attribute("payload") - assert_not type.binary? + rescue ActiveRecord::StatementInvalid + skip "do not test on PostgreSQL without #{column_type} type." end def test_default @connection.add_column "json_data_type", "permissions", column_type, default: { "users": "read", "posts": ["read", "write"] } - JsonDataType.reset_column_information - - assert_equal({ "users" => "read", "posts" => ["read", "write"] }, JsonDataType.column_defaults["permissions"]) - assert_equal({ "users" => "read", "posts" => ["read", "write"] }, JsonDataType.new.permissions) - ensure - JsonDataType.reset_column_information - end - - def test_change_table_supports_json - @connection.transaction do - @connection.change_table("json_data_type") do |t| - t.public_send column_type, "users", default: "{}" # t.json 'users', default: '{}' - end - JsonDataType.reset_column_information - column = JsonDataType.columns_hash["users"] - assert_equal column_type, column.type - - raise ActiveRecord::Rollback # reset the schema change - end - ensure - JsonDataType.reset_column_information - end - - def test_schema_dumping - output = dump_table_schema("json_data_type") - assert_match(/t\.#{column_type.to_s}\s+"payload",\s+default: {}/, output) - end - - def test_cast_value_on_write - x = JsonDataType.new payload: { "string" => "foo", :symbol => :bar } - assert_equal({ "string" => "foo", :symbol => :bar }, x.payload_before_type_cast) - assert_equal({ "string" => "foo", "symbol" => "bar" }, x.payload) - x.save - assert_equal({ "string" => "foo", "symbol" => "bar" }, x.reload.payload) - end - - def test_type_cast_json - type = JsonDataType.type_for_attribute("payload") - - data = "{\"a_key\":\"a_value\"}" - hash = type.deserialize(data) - assert_equal({ "a_key" => "a_value" }, hash) - assert_equal({ "a_key" => "a_value" }, type.deserialize(data)) - - assert_equal({}, type.deserialize("{}")) - assert_equal({ "key" => nil }, type.deserialize('{"key": null}')) - assert_equal({ "c" => "}", '"a"' => 'b "a b' }, type.deserialize(%q({"c":"}", "\"a\"":"b \"a b"}))) - end - - def test_rewrite - @connection.execute "insert into json_data_type (payload) VALUES ('{\"k\":\"v\"}')" - x = JsonDataType.first - x.payload = { '"a\'' => "b" } - assert x.save! - end - - def test_select - @connection.execute "insert into json_data_type (payload) VALUES ('{\"k\":\"v\"}')" - x = JsonDataType.first - assert_equal({ "k" => "v" }, x.payload) - end - - def test_select_multikey - @connection.execute %q|insert into json_data_type (payload) VALUES ('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')| - x = JsonDataType.first - assert_equal({ "k1" => "v1", "k2" => "v2", "k3" => [1, 2, 3] }, x.payload) - end - - def test_null_json - @connection.execute "insert into json_data_type (payload) VALUES(null)" - x = JsonDataType.first - assert_nil(x.payload) - end + klass.reset_column_information - def test_select_nil_json_after_create - json = JsonDataType.create(payload: nil) - x = JsonDataType.where(payload: nil).first - assert_equal(json, x) + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.column_defaults["permissions"]) + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.new.permissions) end - def test_select_nil_json_after_update - json = JsonDataType.create(payload: "foo") - x = JsonDataType.where(payload: nil).first - assert_nil(x) - - json.update_attributes payload: nil - x = JsonDataType.where(payload: nil).first - assert_equal(json.reload, x) - end - - def test_select_array_json_value - @connection.execute %q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')| - x = JsonDataType.first - assert_equal(["v0", { "k1" => "v1" }], x.payload) - end - - def test_rewrite_array_json_value - @connection.execute %q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')| - x = JsonDataType.first - x.payload = ["v1", { "k2" => "v2" }, "v3"] - assert x.save! - end - - def test_with_store_accessors - x = JsonDataType.new(resolution: "320×480") - assert_equal "320×480", x.resolution - + def test_deserialize_with_array + x = klass.new(objects: ["foo" => "bar"]) + assert_equal ["foo" => "bar"], x.objects x.save! - x = JsonDataType.first - assert_equal "320×480", x.resolution - - x.resolution = "640×1136" - x.save! - - x = JsonDataType.first - assert_equal "640×1136", x.resolution - end - - def test_duplication_with_store_accessors - x = JsonDataType.new(resolution: "320×480") - assert_equal "320×480", x.resolution - - y = x.dup - assert_equal "320×480", y.resolution - end - - def test_yaml_round_trip_with_store_accessors - x = JsonDataType.new(resolution: "320×480") - assert_equal "320×480", x.resolution - - y = YAML.load(YAML.dump(x)) - assert_equal "320×480", y.resolution - end - - def test_changes_in_place - json = JsonDataType.new - assert_not json.changed? - - json.payload = { "one" => "two" } - assert json.changed? - assert json.payload_changed? - - json.save! - assert_not json.changed? - - json.payload["three"] = "four" - assert json.payload_changed? - - json.save! - json.reload - - assert_equal({ "one" => "two", "three" => "four" }, json.payload) - assert_not json.changed? - end - - def test_assigning_string_literal - json = JsonDataType.create(payload: "foo") - assert_equal "foo", json.payload - end - - def test_assigning_number - json = JsonDataType.create(payload: 1.234) - assert_equal 1.234, json.payload - end - - def test_assigning_boolean - json = JsonDataType.create(payload: true) - assert_equal true, json.payload + assert_equal ["foo" => "bar"], x.objects + x.reload + assert_equal ["foo" => "bar"], x.objects end end diff --git a/activerecord/test/cases/adapters/postgresql/ltree_test.rb b/activerecord/test/cases/adapters/postgresql/ltree_test.rb index 2b5ac1cac6..8349ee6ee2 100644 --- a/activerecord/test/cases/adapters/postgresql/ltree_test.rb +++ b/activerecord/test/cases/adapters/postgresql/ltree_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -29,10 +31,10 @@ class PostgresqlLtreeTest < ActiveRecord::PostgreSQLTestCase column = Ltree.columns_hash["path"] assert_equal :ltree, column.type assert_equal "ltree", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = Ltree.type_for_attribute("path") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_write diff --git a/activerecord/test/cases/adapters/postgresql/money_test.rb b/activerecord/test/cases/adapters/postgresql/money_test.rb index 1b5d8362af..61e75e772d 100644 --- a/activerecord/test/cases/adapters/postgresql/money_test.rb +++ b/activerecord/test/cases/adapters/postgresql/money_test.rb @@ -1,10 +1,14 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase include SchemaDumpingHelper - class PostgresqlMoney < ActiveRecord::Base; end + class PostgresqlMoney < ActiveRecord::Base + validates :depth, numericality: true + end setup do @connection = ActiveRecord::Base.connection @@ -24,15 +28,16 @@ class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase assert_equal :money, column.type assert_equal "money", column.sql_type assert_equal 2, column.scale - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlMoney.type_for_attribute("wealth") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_default - assert_equal BigDecimal.new("150.55"), PostgresqlMoney.column_defaults["depth"] - assert_equal BigDecimal.new("150.55"), PostgresqlMoney.new.depth + assert_equal BigDecimal("150.55"), PostgresqlMoney.column_defaults["depth"] + assert_equal BigDecimal("150.55"), PostgresqlMoney.new.depth + assert_equal "$150.55", PostgresqlMoney.new.depth_before_type_cast end def test_money_values @@ -47,10 +52,10 @@ class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase def test_money_type_cast type = PostgresqlMoney.type_for_attribute("wealth") - assert_equal(12345678.12, type.cast("$12,345,678.12")) - assert_equal(12345678.12, type.cast("$12.345.678,12")) - assert_equal(-1.15, type.cast("-$1.15")) - assert_equal(-2.25, type.cast("($2.25)")) + assert_equal(12345678.12, type.cast("$12,345,678.12".dup)) + assert_equal(12345678.12, type.cast("$12.345.678,12".dup)) + assert_equal(-1.15, type.cast("-$1.15".dup)) + assert_equal(-2.25, type.cast("($2.25)".dup)) end def test_schema_dumping @@ -60,10 +65,10 @@ class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase end def test_create_and_update_money - money = PostgresqlMoney.create(wealth: "987.65") + money = PostgresqlMoney.create(wealth: "987.65".dup) assert_equal 987.65, money.wealth - new_value = BigDecimal.new("123.45") + new_value = BigDecimal("123.45") money.wealth = new_value money.save! money.reload diff --git a/activerecord/test/cases/adapters/postgresql/network_test.rb b/activerecord/test/cases/adapters/postgresql/network_test.rb index a33b0ef8a7..736570451b 100644 --- a/activerecord/test/cases/adapters/postgresql/network_test.rb +++ b/activerecord/test/cases/adapters/postgresql/network_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -22,30 +24,30 @@ class PostgresqlNetworkTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlNetworkAddress.columns_hash["cidr_address"] assert_equal :cidr, column.type assert_equal "cidr", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlNetworkAddress.type_for_attribute("cidr_address") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_inet_column column = PostgresqlNetworkAddress.columns_hash["inet_address"] assert_equal :inet, column.type assert_equal "inet", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlNetworkAddress.type_for_attribute("inet_address") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_macaddr_column column = PostgresqlNetworkAddress.columns_hash["mac_address"] assert_equal :macaddr, column.type assert_equal "macaddr", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = PostgresqlNetworkAddress.type_for_attribute("mac_address") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_network_types diff --git a/activerecord/test/cases/adapters/postgresql/numbers_test.rb b/activerecord/test/cases/adapters/postgresql/numbers_test.rb index bfb2b7c27a..b53a12254d 100644 --- a/activerecord/test/cases/adapters/postgresql/numbers_test.rb +++ b/activerecord/test/cases/adapters/postgresql/numbers_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlNumberTest < ActiveRecord::PostgreSQLTestCase diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb new file mode 100644 index 0000000000..0ac9ca1200 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/partitions_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgreSQLPartitionsTest < ActiveRecord::PostgreSQLTestCase + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table "partitioned_events", if_exists: true + end + + def test_partitions_table_exists + skip unless ActiveRecord::Base.connection.postgresql_version >= 100000 + @connection.create_table :partitioned_events, force: true, id: false, + options: "partition by range (issued_at)" do |t| + t.timestamp :issued_at + end + assert @connection.table_exists?("partitioned_events") + end +end diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb index 3054f0271f..cbb6cd42b5 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/ddl_helper" require "support/connection_helper" @@ -21,17 +23,6 @@ module ActiveRecord end end - def test_valid_column - with_example_table do - column = @connection.columns("ex").find { |col| col.name == "id" } - assert @connection.valid_type?(column.type) - end - end - - def test_invalid_column - assert_not @connection.valid_type?(:foobar) - end - def test_primary_key with_example_table do assert_equal "id", @connection.primary_key("ex") @@ -257,12 +248,12 @@ module ActiveRecord def test_index_with_opclass with_example_table do - @connection.add_index "ex", "data varchar_pattern_ops" - index = @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data_varchar_pattern_ops" } - assert_equal "data varchar_pattern_ops", index.columns + @connection.add_index "ex", "data", opclass: "varchar_pattern_ops" + index = @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" } + assert_equal ["data"], index.columns - @connection.remove_index "ex", "data varchar_pattern_ops" - assert_not @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data_varchar_pattern_ops" } + @connection.remove_index "ex", "data" + assert_not @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" } end end @@ -272,25 +263,25 @@ module ActiveRecord end def test_columns_for_distinct_one_order - assert_equal "posts.id, posts.created_at AS alias_0", + assert_equal "posts.created_at AS alias_0, posts.id", @connection.columns_for_distinct("posts.id", ["posts.created_at desc"]) end def test_columns_for_distinct_few_orders - assert_equal "posts.id, posts.created_at AS alias_0, posts.position AS alias_1", + assert_equal "posts.created_at AS alias_0, posts.position AS alias_1, posts.id", @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"]) end def test_columns_for_distinct_with_case assert_equal( - "posts.id, CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0", + "CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0, posts.id", @connection.columns_for_distinct("posts.id", ["CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END"]) ) end def test_columns_for_distinct_blank_not_nil_orders - assert_equal "posts.id, posts.created_at AS alias_0", + assert_equal "posts.created_at AS alias_0, posts.id", @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "", " "]) end @@ -299,23 +290,23 @@ module ActiveRecord def order.to_sql "posts.created_at desc" end - assert_equal "posts.id, posts.created_at AS alias_0", + assert_equal "posts.created_at AS alias_0, posts.id", @connection.columns_for_distinct("posts.id", [order]) end def test_columns_for_distinct_with_nulls - assert_equal "posts.title, posts.updater_id AS alias_0", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls first"]) - assert_equal "posts.title, posts.updater_id AS alias_0", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls last"]) + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls first"]) + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls last"]) end def test_columns_for_distinct_without_order_specifiers - assert_equal "posts.title, posts.updater_id AS alias_0", + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id"]) - assert_equal "posts.title, posts.updater_id AS alias_0", + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls last"]) - assert_equal "posts.title, posts.updater_id AS alias_0", + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls first"]) end @@ -335,29 +326,35 @@ module ActiveRecord reset_connection end - def test_only_reload_type_map_once_for_every_unknown_type + def test_only_reload_type_map_once_for_every_unrecognized_type + reset_connection + connection = ActiveRecord::Base.connection + silence_warnings do assert_queries 2, ignore_none: true do - @connection.select_all "SELECT NULL::anyelement" + connection.select_all "select 'pg_catalog.pg_class'::regclass" end assert_queries 1, ignore_none: true do - @connection.select_all "SELECT NULL::anyelement" + connection.select_all "select 'pg_catalog.pg_class'::regclass" end assert_queries 2, ignore_none: true do - @connection.select_all "SELECT NULL::anyarray" + connection.select_all "SELECT NULL::anyarray" end end ensure reset_connection end - def test_only_warn_on_first_encounter_of_unknown_oid + def test_only_warn_on_first_encounter_of_unrecognized_oid + reset_connection + connection = ActiveRecord::Base.connection + warning = capture(:stderr) { - @connection.select_all "SELECT NULL::anyelement" - @connection.select_all "SELECT NULL::anyelement" - @connection.select_all "SELECT NULL::anyelement" + connection.select_all "select 'pg_catalog.pg_class'::regclass" + connection.select_all "select 'pg_catalog.pg_class'::regclass" + connection.select_all "select 'pg_catalog.pg_class'::regclass" } - assert_match(/\Aunknown OID \d+: failed to recognize type of 'anyelement'. It will be treated as String.\n\z/, warning) + assert_match(/\Aunknown OID \d+: failed to recognize type of 'regclass'\. It will be treated as String\.\n\z/, warning) ensure reset_connection end diff --git a/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb b/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb index 8c62690866..f7478b50c3 100644 --- a/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb +++ b/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/computer" require "models/developer" diff --git a/activerecord/test/cases/adapters/postgresql/quoting_test.rb b/activerecord/test/cases/adapters/postgresql/quoting_test.rb index a1e966b915..d50dc49276 100644 --- a/activerecord/test/cases/adapters/postgresql/quoting_test.rb +++ b/activerecord/test/cases/adapters/postgresql/quoting_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -9,11 +11,11 @@ module ActiveRecord end def test_type_cast_true - assert_equal "t", @conn.type_cast(true) + assert_equal true, @conn.type_cast(true) end def test_type_cast_false - assert_equal "f", @conn.type_cast(false) + assert_equal false, @conn.type_cast(false) end def test_quote_float_nan diff --git a/activerecord/test/cases/adapters/postgresql/range_test.rb b/activerecord/test/cases/adapters/postgresql/range_test.rb index f411884dfd..433598500d 100644 --- a/activerecord/test/cases/adapters/postgresql/range_test.rb +++ b/activerecord/test/cases/adapters/postgresql/range_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" @@ -132,10 +134,10 @@ _SQL end def test_numrange_values - assert_equal BigDecimal.new("0.1")..BigDecimal.new("0.2"), @first_range.num_range - assert_equal BigDecimal.new("0.1")...BigDecimal.new("0.2"), @second_range.num_range - assert_equal BigDecimal.new("0.1")...BigDecimal.new("Infinity"), @third_range.num_range - assert_equal BigDecimal.new("-Infinity")...BigDecimal.new("Infinity"), @fourth_range.num_range + assert_equal BigDecimal("0.1")..BigDecimal("0.2"), @first_range.num_range + assert_equal BigDecimal("0.1")...BigDecimal("0.2"), @second_range.num_range + assert_equal BigDecimal("0.1")...BigDecimal("Infinity"), @third_range.num_range + assert_equal BigDecimal("-Infinity")...BigDecimal("Infinity"), @fourth_range.num_range assert_nil @empty_range.num_range end @@ -230,16 +232,67 @@ _SQL end end + def test_create_tstzrange_preserve_usec + tstzrange = Time.parse("2010-01-01 14:30:00.670277 +0100")...Time.parse("2011-02-02 14:30:00.745125 CDT") + round_trip(@new_range, :tstz_range, tstzrange) + assert_equal @new_range.tstz_range, tstzrange + assert_equal @new_range.tstz_range, Time.parse("2010-01-01 13:30:00.670277 UTC")...Time.parse("2011-02-02 19:30:00.745125 UTC") + end + + def test_update_tstzrange_preserve_usec + assert_equal_round_trip(@first_range, :tstz_range, + Time.parse("2010-01-01 14:30:00.245124 CDT")...Time.parse("2011-02-02 14:30:00.451274 CET")) + assert_nil_round_trip(@first_range, :tstz_range, + Time.parse("2010-01-01 14:30:00.245124 +0100")...Time.parse("2010-01-01 13:30:00.245124 +0000")) + end + + def test_create_tsrange_preseve_usec + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@new_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0, 125435)...Time.send(tz, 2011, 2, 2, 14, 30, 0, 225435)) + end + + def test_update_tsrange_preserve_usec + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)...Time.send(tz, 2011, 2, 2, 14, 30, 0, 224242)) + assert_nil_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)...Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)) + end + + def test_timezone_awareness_tsrange_preserve_usec + tz = "Pacific Time (US & Canada)" + + in_time_zone tz do + PostgresqlRange.reset_column_information + time_string = "2017-09-26 07:30:59.132451 -0700" + time = Time.zone.parse(time_string) + assert time.usec > 0 + + record = PostgresqlRange.new(ts_range: time_string..time_string) + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone + assert_equal time.usec, record.ts_range.begin.usec + + record.save! + record.reload + + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone + assert_equal time.usec, record.ts_range.begin.usec + end + end + def test_create_numrange assert_equal_round_trip(@new_range, :num_range, - BigDecimal.new("0.5")...BigDecimal.new("1")) + BigDecimal("0.5")...BigDecimal("1")) end def test_update_numrange assert_equal_round_trip(@first_range, :num_range, - BigDecimal.new("0.5")...BigDecimal.new("1")) + BigDecimal("0.5")...BigDecimal("1")) assert_nil_round_trip(@first_range, :num_range, - BigDecimal.new("0.5")...BigDecimal.new("0.5")) + BigDecimal("0.5")...BigDecimal("0.5")) end def test_create_daterange @@ -288,6 +341,12 @@ _SQL assert_equal record, PostgresqlRange.where(int4_range: range).take end + def test_where_by_attribute_with_range_in_array + range = 1..100 + record = PostgresqlRange.create!(int4_range: range) + assert_equal record, PostgresqlRange.where(int4_range: [range]).take + end + def test_update_all_with_ranges PostgresqlRange.create! @@ -305,6 +364,18 @@ _SQL end end + def test_infinity_values + PostgresqlRange.create!(int4_range: 1..Float::INFINITY, + int8_range: -Float::INFINITY..0, + float_range: -Float::INFINITY..Float::INFINITY) + + record = PostgresqlRange.first + + assert_equal(1...Float::INFINITY, record.int4_range) + assert_equal(-Float::INFINITY...1, record.int8_range) + assert_equal(-Float::INFINITY...Float::INFINITY, record.float_range) + end + private def assert_equal_round_trip(range, attribute, value) round_trip(range, attribute, value) diff --git a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb index 0ff04bfa27..0bcc214c24 100644 --- a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb +++ b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" diff --git a/activerecord/test/cases/adapters/postgresql/rename_table_test.rb b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb index e9e7f717ac..100d247113 100644 --- a/activerecord/test/cases/adapters/postgresql/rename_table_test.rb +++ b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlRenameTableTest < ActiveRecord::PostgreSQLTestCase diff --git a/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb index f6a07da85f..fcb0aec81b 100644 --- a/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb +++ b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class SchemaThing < ActiveRecord::Base @@ -75,17 +77,6 @@ class SchemaAuthorizationTest < ActiveRecord::PostgreSQLTestCase end end - def test_schema_uniqueness - assert_nothing_raised do - set_session_auth - USERS.each do |u| - set_session_auth u - assert_equal u, @connection.select_value("SELECT name FROM #{TABLE_NAME} WHERE id = 1") - set_session_auth - end - end - end - def test_sequence_schema_caching assert_nothing_raised do USERS.each do |u| diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb index 7b065ff320..a36d066c80 100644 --- a/activerecord/test/cases/adapters/postgresql/schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/default" require "support/schema_dumping_helper" @@ -91,6 +93,7 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase @connection.execute "CREATE INDEX #{INDEX_E_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING gin (#{INDEX_E_COLUMN});" @connection.execute "CREATE INDEX #{INDEX_E_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING gin (#{INDEX_E_COLUMN});" @connection.execute "CREATE TABLE #{SCHEMA_NAME}.#{PK_TABLE_NAME} (id serial primary key)" + @connection.execute "CREATE TABLE #{SCHEMA2_NAME}.#{PK_TABLE_NAME} (id serial primary key)" @connection.execute "CREATE SEQUENCE #{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}" @connection.execute "CREATE TABLE #{SCHEMA_NAME}.#{UNMATCHED_PK_TABLE_NAME} (id integer NOT NULL DEFAULT nextval('#{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}'::regclass), CONSTRAINT unmatched_pkey PRIMARY KEY (id))" end @@ -201,12 +204,12 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase def test_data_source_exists_when_not_on_schema_search_path with_schema_search_path("PUBLIC") do - assert(!@connection.data_source_exists?(TABLE_NAME), "data_source exists but should not be found") + assert_not(@connection.data_source_exists?(TABLE_NAME), "data_source exists but should not be found") end end def test_data_source_exists_wrong_schema - assert(!@connection.data_source_exists?("foo.things"), "data_source should not exist") + assert_not(@connection.data_source_exists?("foo.things"), "data_source should not exist") end def test_data_source_exists_quoted_names @@ -361,7 +364,7 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase end def test_primary_key_assuming_schema_search_path - with_schema_search_path(SCHEMA_NAME) do + with_schema_search_path("#{SCHEMA_NAME}, #{SCHEMA2_NAME}") do assert_equal "id", @connection.primary_key(PK_TABLE_NAME), "primary key should be found" end end @@ -456,7 +459,7 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase assert_equal :btree, index_d.using assert_equal :gin, index_e.using - assert_equal :desc, index_d.orders[INDEX_D_COLUMN] + assert_equal :desc, index_d.orders end end @@ -497,6 +500,66 @@ class SchemaForeignKeyTest < ActiveRecord::PostgreSQLTestCase end end +class SchemaIndexOpclassTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "trains" do |t| + t.string :name + t.text :description + end + end + + teardown do + @connection.drop_table "trains", if_exists: true + end + + def test_string_opclass_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name text_pattern_ops, description text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: :text_pattern_ops/, output) + end + + def test_non_default_opclass_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name, description text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: \{ description: :text_pattern_ops \}/, output) + end +end + +class SchemaIndexNullsOrderTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "trains" do |t| + t.string :name + t.text :description + end + end + + teardown do + @connection.drop_table "trains", if_exists: true + end + + def test_nulls_order_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name NULLS FIRST, description)" + output = dump_table_schema "trains" + assert_match(/order: \{ name: "NULLS FIRST" \}/, output) + end + + def test_non_default_order_with_nulls_is_dumped + @connection.execute "CREATE INDEX trains_name_and_desc ON trains USING btree(name DESC NULLS LAST, description)" + output = dump_table_schema "trains" + assert_match(/order: \{ name: "DESC NULLS LAST" \}/, output) + end +end + class DefaultsUsingMultipleSchemasAndDomainTest < ActiveRecord::PostgreSQLTestCase setup do @connection = ActiveRecord::Base.connection @@ -531,7 +594,7 @@ class DefaultsUsingMultipleSchemasAndDomainTest < ActiveRecord::PostgreSQLTestCa end def test_decimal_defaults_in_new_schema_when_overriding_domain - assert_equal BigDecimal.new("3.14159265358979323846"), Default.new.decimal_col, "Default of decimal column was not correctly parsed" + assert_equal BigDecimal("3.14159265358979323846"), Default.new.decimal_col, "Default of decimal column was not correctly parsed" end def test_bpchar_defaults_in_new_schema_when_overriding_domain diff --git a/activerecord/test/cases/adapters/postgresql/serial_test.rb b/activerecord/test/cases/adapters/postgresql/serial_test.rb index d711b3b729..83ea86be6d 100644 --- a/activerecord/test/cases/adapters/postgresql/serial_test.rb +++ b/activerecord/test/cases/adapters/postgresql/serial_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -22,14 +24,14 @@ class PostgresqlSerialTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlSerial.columns_hash["seq"] assert_equal :integer, column.type assert_equal "integer", column.sql_type - assert column.serial? + assert_predicate column, :serial? end def test_not_serial_column column = PostgresqlSerial.columns_hash["serials_id"] assert_equal :integer, column.type assert_equal "integer", column.sql_type - assert_not column.serial? + assert_not_predicate column, :serial? end def test_schema_dump_with_shorthand @@ -64,14 +66,14 @@ class PostgresqlBigSerialTest < ActiveRecord::PostgreSQLTestCase column = PostgresqlBigSerial.columns_hash["seq"] assert_equal :integer, column.type assert_equal "bigint", column.sql_type - assert column.serial? + assert_predicate column, :serial? end def test_not_bigserial_column column = PostgresqlBigSerial.columns_hash["serials_id"] assert_equal :integer, column.type assert_equal "bigint", column.sql_type - assert_not column.serial? + assert_not_predicate column, :serial? end def test_schema_dump_with_shorthand @@ -84,3 +86,71 @@ class PostgresqlBigSerialTest < ActiveRecord::PostgreSQLTestCase assert_match %r{t\.bigint\s+"serials_id",\s+default: -> \{ "nextval\('postgresql_big_serials_id_seq'::regclass\)" \}$}, output end end + +module SequenceNameDetectionTestCases + class CollidedSequenceNameTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :foo_bar, force: true do |t| + t.serial :baz_id + end + @connection.create_table :foo, force: true do |t| + t.serial :bar_id + t.bigserial :bar_baz_id + end + end + + def teardown + @connection.drop_table :foo_bar, if_exists: true + @connection.drop_table :foo, if_exists: true + end + + def test_serial_columns + columns = @connection.columns(:foo) + columns.each do |column| + assert_equal :integer, column.type + assert_predicate column, :serial? + end + end + + def test_schema_dump_with_collided_sequence_name + output = dump_table_schema "foo" + assert_match %r{t\.serial\s+"bar_id",\s+null: false$}, output + assert_match %r{t\.bigserial\s+"bar_baz_id",\s+null: false$}, output + end + end + + class LongerSequenceNameDetectionTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + def setup + @table_name = "long_table_name_to_test_sequence_name_detection_for_serial_cols" + @connection = ActiveRecord::Base.connection + @connection.create_table @table_name, force: true do |t| + t.serial :seq + t.bigserial :bigseq + end + end + + def teardown + @connection.drop_table @table_name, if_exists: true + end + + def test_serial_columns + columns = @connection.columns(@table_name) + columns.each do |column| + assert_equal :integer, column.type + assert_predicate column, :serial? + end + end + + def test_schema_dump_with_long_table_name + output = dump_table_schema @table_name + assert_match %r{create_table "#{@table_name}", force: :cascade}, output + assert_match %r{t\.serial\s+"seq",\s+null: false$}, output + assert_match %r{t\.bigserial\s+"bigseq",\s+null: false$}, output + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb index eb9978a898..fef4b02b04 100644 --- a/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb +++ b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb @@ -1,15 +1,17 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord module ConnectionAdapters class PostgreSQLAdapter < AbstractAdapter - class InactivePGconn + class InactivePgConnection def query(*args) - raise PGError + raise PG::Error end def status - PGconn::CONNECTION_BAD + PG::CONNECTION_BAD end end @@ -21,7 +23,7 @@ module ActiveRecord assert_equal "bar", cache["foo"] pid = fork { - lookup = cache["foo"]; + lookup = cache["foo"] exit!(!lookup) } @@ -31,7 +33,7 @@ module ActiveRecord end def test_dealloc_does_not_raise_on_inactive_connection - cache = StatementPool.new InactivePGconn.new, 10 + cache = StatementPool.new InactivePgConnection.new, 10 cache["foo"] = "bar" assert_nothing_raised { cache.clear } end diff --git a/activerecord/test/cases/adapters/postgresql/timestamp_test.rb b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb index 962450aada..b7f213efc8 100644 --- a/activerecord/test/cases/adapters/postgresql/timestamp_test.rb +++ b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/developer" require "models/topic" diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb index 9b42d0383d..984b2f5ea4 100644 --- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb +++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" require "concurrent/atomic/cyclic_barrier" @@ -12,6 +14,7 @@ module ActiveRecord setup do @abort, Thread.abort_on_exception = Thread.abort_on_exception, false + Thread.report_on_exception, @original_report_on_exception = false, Thread.report_on_exception @connection = ActiveRecord::Base.connection @@ -29,6 +32,7 @@ module ActiveRecord @connection.drop_table "samples", if_exists: true Thread.abort_on_exception = @abort + Thread.report_on_exception = @original_report_on_exception end test "raises SerializationFailure when a serialization failure occurs" do @@ -72,7 +76,7 @@ module ActiveRecord Sample.transaction do s1.lock! barrier.wait - s2.update_attributes value: 1 + s2.update value: 1 end end @@ -80,7 +84,7 @@ module ActiveRecord Sample.transaction do s2.lock! barrier.wait - s1.update_attributes value: 2 + s1.update value: 2 end ensure thread.join @@ -89,6 +93,90 @@ module ActiveRecord end end + test "raises LockWaitTimeout when lock wait timeout exceeded" do + skip unless ActiveRecord::Base.connection.postgresql_version >= 90300 + assert_raises(ActiveRecord::LockWaitTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET lock_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET lock_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when statement timeout exceeded" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET statement_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET statement_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when canceling statement due to user request" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch.count_down + sleep(0.5) + conn = Sample.connection + pid = conn.query_value("SELECT pid FROM pg_stat_activity WHERE query LIKE '% FOR UPDATE'") + conn.execute("SELECT pg_cancel_backend(#{pid})") + end + end + + begin + Sample.transaction do + latch.wait + Sample.lock.find(s.id) + end + ensure + thread.join + end + end + end + private def with_warning_suppression diff --git a/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb index 784d77a8d1..8212ed4263 100644 --- a/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb +++ b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class PostgresqlTypeLookupTest < ActiveRecord::PostgreSQLTestCase @@ -6,16 +8,16 @@ class PostgresqlTypeLookupTest < ActiveRecord::PostgreSQLTestCase end test "array delimiters are looked up correctly" do - box_array = @connection.type_map.lookup(1020) - int_array = @connection.type_map.lookup(1007) + box_array = @connection.send(:type_map).lookup(1020) + int_array = @connection.send(:type_map).lookup(1007) assert_equal ";", box_array.delimiter assert_equal ",", int_array.delimiter end test "array types correctly respect registration of subtypes" do - int_array = @connection.type_map.lookup(1007, -1, "integer[]") - bigint_array = @connection.type_map.lookup(1016, -1, "bigint[]") + int_array = @connection.send(:type_map).lookup(1007, -1, "integer[]") + bigint_array = @connection.send(:type_map).lookup(1016, -1, "bigint[]") big_array = [123456789123456789] assert_raises(ActiveModel::RangeError) { int_array.serialize(big_array) } @@ -23,11 +25,11 @@ class PostgresqlTypeLookupTest < ActiveRecord::PostgreSQLTestCase end test "range types correctly respect registration of subtypes" do - int_range = @connection.type_map.lookup(3904, -1, "int4range") - bigint_range = @connection.type_map.lookup(3926, -1, "int8range") + int_range = @connection.send(:type_map).lookup(3904, -1, "int4range") + bigint_range = @connection.send(:type_map).lookup(3926, -1, "int8range") big_range = 0..123456789123456789 assert_raises(ActiveModel::RangeError) { int_range.serialize(big_range) } - assert_equal "[0,123456789123456789]", bigint_range.serialize(big_range) + assert_equal "[0,123456789123456789]", @connection.type_cast(bigint_range.serialize(big_range)) end end diff --git a/activerecord/test/cases/adapters/postgresql/utils_test.rb b/activerecord/test/cases/adapters/postgresql/utils_test.rb index 9f9e3bda2f..c91884f384 100644 --- a/activerecord/test/cases/adapters/postgresql/utils_test.rb +++ b/activerecord/test/cases/adapters/postgresql/utils_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_record/connection_adapters/postgresql/utils" diff --git a/activerecord/test/cases/adapters/postgresql/uuid_test.rb b/activerecord/test/cases/adapters/postgresql/uuid_test.rb index 6aa6a79705..71d07e2f4c 100644 --- a/activerecord/test/cases/adapters/postgresql/uuid_test.rb +++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -13,6 +15,10 @@ module PostgresqlUUIDHelper def uuid_function connection.supports_pgcrypto_uuid? ? "gen_random_uuid()" : "uuid_generate_v4()" end + + def uuid_default + connection.supports_pgcrypto_uuid? ? {} : { default: uuid_function } + end end class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase @@ -36,7 +42,8 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase drop_table "uuid_data_type" end - if ActiveRecord::Base.connection.supports_pgcrypto_uuid? + if ActiveRecord::Base.connection.respond_to?(:supports_pgcrypto_uuid?) && + ActiveRecord::Base.connection.supports_pgcrypto_uuid? def test_uuid_column_default connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "gen_random_uuid()" UUIDType.reset_column_information @@ -59,14 +66,37 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase UUIDType.reset_column_information end + def test_add_column_with_null_true_and_default_nil + connection.add_column :uuid_data_type, :thingy, :uuid, null: true, default: nil + + UUIDType.reset_column_information + column = UUIDType.columns_hash["thingy"] + + assert column.null + assert_nil column.default + end + + def test_add_column_with_default_array + connection.add_column :uuid_data_type, :thingy, :uuid, array: true, default: [] + + UUIDType.reset_column_information + column = UUIDType.columns_hash["thingy"] + + assert_predicate column, :array? + assert_equal "{}", column.default + + schema = dump_table_schema "uuid_data_type" + assert_match %r{t\.uuid "thingy", default: \[\], array: true$}, schema + end + def test_data_type_of_uuid_types column = UUIDType.columns_hash["guid"] assert_equal :uuid, column.type assert_equal "uuid", column.sql_type - assert_not column.array? + assert_not_predicate column, :array? type = UUIDType.type_for_attribute("guid") - assert_not type.binary? + assert_not_predicate type, :binary? end def test_treat_blank_uuid_as_nil @@ -109,7 +139,9 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase "Z0000C99-9C0B-4EF8-BB6D-6BB9BD380A11", "a0eebc999r0b4ef8ab6d6bb9bd380a11", "a0ee-bc99------4ef8-bb6d-6bb9-bd38-0a11", - "{a0eebc99-bb6d6bb9-bd380a11}"].each do |invalid_uuid| + "{a0eebc99-bb6d6bb9-bd380a11}", + "{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11", + "a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}"].each do |invalid_uuid| uuid = UUIDType.new guid: invalid_uuid assert_nil uuid.guid end @@ -146,7 +178,7 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase duplicate = klass.new(guid: record.guid) assert record.guid.present? # Ensure we actually are testing a UUID - assert_not duplicate.valid? + assert_not_predicate duplicate, :valid? end end @@ -178,7 +210,7 @@ class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase t.uuid "other_uuid_2", default: "my_uuid_generator()" end - connection.create_table("pg_uuids_3", id: :uuid) do |t| + connection.create_table("pg_uuids_3", id: :uuid, **uuid_default) do |t| t.string "name" end end @@ -190,68 +222,66 @@ class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase connection.execute "DROP FUNCTION IF EXISTS my_uuid_generator();" end - if ActiveRecord::Base.connection.supports_extensions? - def test_id_is_uuid - assert_equal :uuid, UUID.columns_hash["id"].type - assert UUID.primary_key - end + def test_id_is_uuid + assert_equal :uuid, UUID.columns_hash["id"].type + assert UUID.primary_key + end - def test_id_has_a_default - u = UUID.create - assert_not_nil u.id - end + def test_id_has_a_default + u = UUID.create + assert_not_nil u.id + end - def test_auto_create_uuid - u = UUID.create - u.reload - assert_not_nil u.other_uuid - end + def test_auto_create_uuid + u = UUID.create + u.reload + assert_not_nil u.other_uuid + end - def test_pk_and_sequence_for_uuid_primary_key - pk, seq = connection.pk_and_sequence_for("pg_uuids") - assert_equal "id", pk - assert_nil seq - end + def test_pk_and_sequence_for_uuid_primary_key + pk, seq = connection.pk_and_sequence_for("pg_uuids") + assert_equal "id", pk + assert_nil seq + end - def test_schema_dumper_for_uuid_primary_key - schema = dump_table_schema "pg_uuids" - assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: -> { "uuid_generate_v1\(\)" }/, schema) - assert_match(/t\.uuid "other_uuid", default: -> { "uuid_generate_v4\(\)" }/, schema) - end + def test_schema_dumper_for_uuid_primary_key + schema = dump_table_schema "pg_uuids" + assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: -> { "uuid_generate_v1\(\)" }/, schema) + assert_match(/t\.uuid "other_uuid", default: -> { "uuid_generate_v4\(\)" }/, schema) + end + + def test_schema_dumper_for_uuid_primary_key_with_custom_default + schema = dump_table_schema "pg_uuids_2" + assert_match(/\bcreate_table "pg_uuids_2", id: :uuid, default: -> { "my_uuid_generator\(\)" }/, schema) + assert_match(/t\.uuid "other_uuid_2", default: -> { "my_uuid_generator\(\)" }/, schema) + end - def test_schema_dumper_for_uuid_primary_key_with_custom_default - schema = dump_table_schema "pg_uuids_2" - assert_match(/\bcreate_table "pg_uuids_2", id: :uuid, default: -> { "my_uuid_generator\(\)" }/, schema) - assert_match(/t\.uuid "other_uuid_2", default: -> { "my_uuid_generator\(\)" }/, schema) + def test_schema_dumper_for_uuid_primary_key_default + schema = dump_table_schema "pg_uuids_3" + if connection.supports_pgcrypto_uuid? + assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "gen_random_uuid\(\)" }/, schema) + else + assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema) end + end + + def test_schema_dumper_for_uuid_primary_key_default_in_legacy_migration + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false - def test_schema_dumper_for_uuid_primary_key_default - schema = dump_table_schema "pg_uuids_3" - if connection.supports_pgcrypto_uuid? - assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "gen_random_uuid\(\)" }/, schema) - else - assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema) + migration = Class.new(ActiveRecord::Migration[5.0]) do + def version; 101 end + def migrate(x) + create_table("pg_uuids_4", id: :uuid) end - end + end.new + ActiveRecord::Migrator.new(:up, [migration]).migrate - def test_schema_dumper_for_uuid_primary_key_default_in_legacy_migration - @verbose_was = ActiveRecord::Migration.verbose - ActiveRecord::Migration.verbose = false - - migration = Class.new(ActiveRecord::Migration[5.0]) do - def version; 101 end - def migrate(x) - create_table("pg_uuids_4", id: :uuid) - end - end.new - ActiveRecord::Migrator.new(:up, [migration]).migrate - - schema = dump_table_schema "pg_uuids_4" - assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema) - ensure - drop_table "pg_uuids_4" - ActiveRecord::Migration.verbose = @verbose_was - end + schema = dump_table_schema "pg_uuids_4" + assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema) + ensure + drop_table "pg_uuids_4" + ActiveRecord::Migration.verbose = @verbose_was end end @@ -270,38 +300,36 @@ class PostgresqlUUIDTestNilDefault < ActiveRecord::PostgreSQLTestCase drop_table "pg_uuids" end - if ActiveRecord::Base.connection.supports_extensions? - def test_id_allows_default_override_via_nil - col_desc = connection.execute("SELECT pg_get_expr(d.adbin, d.adrelid) as default - FROM pg_attribute a - LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum - WHERE a.attname='id' AND a.attrelid = 'pg_uuids'::regclass").first - assert_nil col_desc["default"] - end + def test_id_allows_default_override_via_nil + col_desc = connection.execute("SELECT pg_get_expr(d.adbin, d.adrelid) as default + FROM pg_attribute a + LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum + WHERE a.attname='id' AND a.attrelid = 'pg_uuids'::regclass").first + assert_nil col_desc["default"] + end - def test_schema_dumper_for_uuid_primary_key_with_default_override_via_nil - schema = dump_table_schema "pg_uuids" - assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: nil/, schema) - end + def test_schema_dumper_for_uuid_primary_key_with_default_override_via_nil + schema = dump_table_schema "pg_uuids" + assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: nil/, schema) + end - def test_schema_dumper_for_uuid_primary_key_with_default_nil_in_legacy_migration - @verbose_was = ActiveRecord::Migration.verbose - ActiveRecord::Migration.verbose = false - - migration = Class.new(ActiveRecord::Migration[5.0]) do - def version; 101 end - def migrate(x) - create_table("pg_uuids_4", id: :uuid, default: nil) - end - end.new - ActiveRecord::Migrator.new(:up, [migration]).migrate - - schema = dump_table_schema "pg_uuids_4" - assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: nil/, schema) - ensure - drop_table "pg_uuids_4" - ActiveRecord::Migration.verbose = @verbose_was - end + def test_schema_dumper_for_uuid_primary_key_with_default_nil_in_legacy_migration + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + migration = Class.new(ActiveRecord::Migration[5.0]) do + def version; 101 end + def migrate(x) + create_table("pg_uuids_4", id: :uuid, default: nil) + end + end.new + ActiveRecord::Migrator.new(:up, [migration]).migrate + + schema = dump_table_schema "pg_uuids_4" + assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: nil/, schema) + ensure + drop_table "pg_uuids_4" + ActiveRecord::Migration.verbose = @verbose_was end end @@ -320,10 +348,10 @@ class PostgresqlUUIDTestInverseOf < ActiveRecord::PostgreSQLTestCase setup do connection.transaction do - connection.create_table("pg_uuid_posts", id: :uuid) do |t| + connection.create_table("pg_uuid_posts", id: :uuid, **uuid_default) do |t| t.string "title" end - connection.create_table("pg_uuid_comments", id: :uuid) do |t| + connection.create_table("pg_uuid_comments", id: :uuid, **uuid_default) do |t| t.references :uuid_post, type: :uuid t.string "content" end @@ -335,23 +363,21 @@ class PostgresqlUUIDTestInverseOf < ActiveRecord::PostgreSQLTestCase drop_table "pg_uuid_posts" end - if ActiveRecord::Base.connection.supports_extensions? - def test_collection_association_with_uuid - post = UuidPost.create! - comment = post.uuid_comments.create! - assert post.uuid_comments.find(comment.id) - end + def test_collection_association_with_uuid + post = UuidPost.create! + comment = post.uuid_comments.create! + assert post.uuid_comments.find(comment.id) + end - def test_find_with_uuid - UuidPost.create! - assert_raise ActiveRecord::RecordNotFound do - UuidPost.find(123456) - end + def test_find_with_uuid + UuidPost.create! + assert_raise ActiveRecord::RecordNotFound do + UuidPost.find(123456) end + end - def test_find_by_with_uuid - UuidPost.create! - assert_nil UuidPost.find_by(id: 789) - end + def test_find_by_with_uuid + UuidPost.create! + assert_nil UuidPost.find_by(id: 789) end end diff --git a/activerecord/test/cases/adapters/postgresql/xml_test.rb b/activerecord/test/cases/adapters/postgresql/xml_test.rb index 826b384fb3..71ead6f7f3 100644 --- a/activerecord/test/cases/adapters/postgresql/xml_test.rb +++ b/activerecord/test/cases/adapters/postgresql/xml_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" diff --git a/activerecord/test/cases/adapters/sqlite3/collation_test.rb b/activerecord/test/cases/adapters/sqlite3/collation_test.rb index 28e8f12c18..76c8f7d8dd 100644 --- a/activerecord/test/cases/adapters/sqlite3/collation_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/collation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -47,7 +49,7 @@ class SQLite3CollationTest < ActiveRecord::SQLite3TestCase test "schema dump includes collation" do output = dump_table_schema("collation_table_sqlite3") - assert_match %r{t.string\s+"string_nocase",\s+collation: "NOCASE"$}, output - assert_match %r{t.text\s+"text_rtrim",\s+collation: "RTRIM"$}, output + assert_match %r{t\.string\s+"string_nocase",\s+collation: "NOCASE"$}, output + assert_match %r{t\.text\s+"text_rtrim",\s+collation: "RTRIM"$}, output end end diff --git a/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb index e1cfd703e8..ffb1d6afce 100644 --- a/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class CopyTableTest < ActiveRecord::SQLite3TestCase diff --git a/activerecord/test/cases/adapters/sqlite3/explain_test.rb b/activerecord/test/cases/adapters/sqlite3/explain_test.rb index 128acb79cf..b6d2ccdb53 100644 --- a/activerecord/test/cases/adapters/sqlite3/explain_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/explain_test.rb @@ -1,21 +1,23 @@ +# frozen_string_literal: true + require "cases/helper" -require "models/developer" -require "models/computer" +require "models/author" +require "models/post" class SQLite3ExplainTest < ActiveRecord::SQLite3TestCase - fixtures :developers + fixtures :authors def test_explain_for_one_query - explain = Developer.where(id: 1).explain - assert_match %r(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = (?:\? \[\["id", 1\]\]|1)), explain - assert_match(/(SEARCH )?TABLE developers USING (INTEGER )?PRIMARY KEY/, explain) + explain = Author.where(id: 1).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain + assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain) end def test_explain_with_eager_loading - explain = Developer.where(id: 1).includes(:audit_logs).explain - assert_match %r(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = (?:\? \[\["id", 1\]\]|1)), explain - assert_match(/(SEARCH )?TABLE developers USING (INTEGER )?PRIMARY KEY/, explain) - assert_match %(EXPLAIN for: SELECT "audit_logs".* FROM "audit_logs" WHERE "audit_logs"."developer_id" = 1), explain - assert_match(/(SCAN )?TABLE audit_logs/, explain) + explain = Author.where(id: 1).includes(:posts).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain + assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain) + assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\? \[\["author_id", 1\]\]|1)), explain + assert_match(/(SCAN )?TABLE posts/, explain) end end diff --git a/activerecord/test/cases/adapters/sqlite3/json_test.rb b/activerecord/test/cases/adapters/sqlite3/json_test.rb new file mode 100644 index 0000000000..6f247fcd22 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/json_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/json_shared_test_cases" + +class SQLite3JSONTest < ActiveRecord::SQLite3TestCase + include JSONSharedTestCases + + def setup + super + @connection.create_table("json_data_type") do |t| + t.json "payload", default: {} + t.json "settings" + end + end + + def test_default + @connection.add_column "json_data_type", "permissions", column_type, default: { "users": "read", "posts": ["read", "write"] } + klass.reset_column_information + + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.column_defaults["permissions"]) + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.new.permissions) + end + + private + def column_type + :json + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb index aefbb309e6..40b58e86bf 100644 --- a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "bigdecimal" require "securerandom" @@ -5,6 +7,11 @@ require "securerandom" class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase def setup @conn = ActiveRecord::Base.connection + @initial_represent_boolean_as_integer = ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer + end + + def teardown + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = @initial_represent_boolean_as_integer end def test_type_cast_binary_encoding_without_logger @@ -15,15 +22,23 @@ class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase end def test_type_cast_true + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = false assert_equal "t", @conn.type_cast(true) + + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true + assert_equal 1, @conn.type_cast(true) end def test_type_cast_false + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = false assert_equal "f", @conn.type_cast(false) + + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true + assert_equal 0, @conn.type_cast(false) end def test_type_cast_bigdecimal - bd = BigDecimal.new "10.0" + bd = BigDecimal "10.0" assert_equal bd.to_f, @conn.type_cast(bd) end @@ -40,4 +55,37 @@ class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase assert_equal "'2000-01-01 12:30:00.999999'", @conn.quote(type.serialize(value)) end + + def test_quoted_time_normalizes_date_qualified_time + value = ::Time.utc(2018, 3, 11, 12, 30, 0, 999999) + type = ActiveRecord::Type::Time.new + + assert_equal "'2000-01-01 12:30:00.999999'", @conn.quote(type.serialize(value)) + end + + def test_quoted_time_dst_utc + with_env_tz "America/New_York" do + with_timezone_config default: :utc do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getutc.to_s(:db).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") + + assert_equal expected, @conn.quoted_time(t) + end + end + end + + def test_quoted_time_dst_local + with_env_tz "America/New_York" do + with_timezone_config default: :local do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") + + assert_equal expected, @conn.quoted_time(t) + end + end + end end diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb index a6afb7816b..d1d4d545a3 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/owner" require "tempfile" @@ -49,22 +51,6 @@ module ActiveRecord end end - def test_valid_column - with_example_table do - column = @conn.columns("ex").find { |col| col.name == "id" } - assert @conn.valid_type?(column.type) - end - end - - # sqlite3 databases should be able to support any type and not just the - # ones mentioned in the native_database_types. - # - # Therefore test_invalid column should always return true even if the - # type is not valid. - def test_invalid_column - assert @conn.valid_type?(:foobar) - end - def test_column_types owner = Owner.create!(name: "hello".encode("ascii-8bit")) owner.reload @@ -75,7 +61,7 @@ module ActiveRecord WHERE #{Owner.primary_key} = #{owner.id} esql - assert(!result.rows.first.include?("blob"), "should not store blobs") + assert_not(result.rows.first.include?("blob"), "should not store blobs") ensure owner.delete end @@ -181,7 +167,7 @@ module ActiveRecord data binary ) eosql - str = "\x80".force_encoding("ASCII-8BIT") + str = "\x80".dup.force_encoding("ASCII-8BIT") binary = DualEncoding.new name: "いただきます!", data: str binary.save! assert_equal str, binary.data @@ -190,7 +176,7 @@ module ActiveRecord end def test_type_cast_should_not_mutate_encoding - name = "hello".force_encoding(Encoding::ASCII_8BIT) + name = "hello".dup.force_encoding(Encoding::ASCII_8BIT) Owner.create(name: name) assert_equal Encoding::ASCII_8BIT, name.encoding ensure @@ -276,27 +262,17 @@ module ActiveRecord def test_tables_logs_name sql = <<-SQL - SELECT name FROM sqlite_master - WHERE type = 'table' AND name <> 'sqlite_sequence' + SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND type IN ('table') SQL assert_logged [[sql.squish, "SCHEMA", []]] do @conn.tables end end - def test_indexes_logs_name - with_example_table do - assert_logged [["PRAGMA index_list(\"ex\")", "SCHEMA", []]] do - assert_deprecated { @conn.indexes("ex", "hello") } - end - end - end - def test_table_exists_logs_name with_example_table do sql = <<-SQL - SELECT name FROM sqlite_master - WHERE type = 'table' AND name <> 'sqlite_sequence' AND name = 'ex' + SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND name = 'ex' AND type IN ('table') SQL assert_logged [[sql.squish, "SCHEMA", []]] do assert @conn.table_exists?("ex") @@ -384,16 +360,126 @@ module ActiveRecord end end + class Barcode < ActiveRecord::Base + self.primary_key = "code" + end + + def test_copy_table_with_existing_records_have_custom_primary_key + connection = Barcode.connection + connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true) do |t| + t.text :other_attr + end + code = "214fe0c2-dd47-46df-b53b-66090b3c1d40" + Barcode.create!(code: code, other_attr: "xxx") + + connection.remove_column("barcodes", "other_attr") + + assert_equal code, Barcode.first.id + ensure + Barcode.reset_column_information + end + + def test_copy_table_with_composite_primary_keys + connection = Barcode.connection + connection.create_table(:barcodes, primary_key: ["region", "code"], force: true) do |t| + t.string :region + t.string :code + t.text :other_attr + end + region = "US" + code = "214fe0c2-dd47-46df-b53b-66090b3c1d40" + Barcode.create!(region: region, code: code, other_attr: "xxx") + + connection.remove_column("barcodes", "other_attr") + + assert_equal ["region", "code"], connection.primary_keys("barcodes") + + barcode = Barcode.first + assert_equal region, barcode.region + assert_equal code, barcode.code + ensure + Barcode.reset_column_information + end + + def test_custom_primary_key_in_create_table + connection = Barcode.connection + connection.create_table :barcodes, id: false, force: true do |t| + t.primary_key :id, :string + end + + assert_equal "id", connection.primary_key("barcodes") + + custom_pk = Barcode.columns_hash["id"] + + assert_equal :string, custom_pk.type + assert_not custom_pk.null + ensure + Barcode.reset_column_information + end + + def test_custom_primary_key_in_change_table + connection = Barcode.connection + connection.create_table :barcodes, id: false, force: true do |t| + t.integer :dummy + end + connection.change_table :barcodes do |t| + t.primary_key :id, :string + end + + assert_equal "id", connection.primary_key("barcodes") + + custom_pk = Barcode.columns_hash["id"] + + assert_equal :string, custom_pk.type + assert_not custom_pk.null + ensure + Barcode.reset_column_information + end + + def test_add_column_with_custom_primary_key + connection = Barcode.connection + connection.create_table :barcodes, id: false, force: true do |t| + t.integer :dummy + end + connection.add_column :barcodes, :id, :string, primary_key: true + + assert_equal "id", connection.primary_key("barcodes") + + custom_pk = Barcode.columns_hash["id"] + + assert_equal :string, custom_pk.type + assert_not custom_pk.null + ensure + Barcode.reset_column_information + end + + def test_remove_column_preserves_partial_indexes + connection = Barcode.connection + connection.create_table :barcodes, force: true do |t| + t.string :code + t.string :region + t.boolean :bool_attr + + t.index :code, unique: true, where: :bool_attr, name: "partial" + end + connection.remove_column :barcodes, :region + + index = connection.indexes("barcodes").find { |idx| idx.name == "partial" } + assert_equal "bool_attr", index.where + ensure + Barcode.reset_column_information + end + def test_supports_extensions assert_not @conn.supports_extensions?, "does not support extensions" end def test_respond_to_enable_extension - assert @conn.respond_to?(:enable_extension) + assert_respond_to @conn, :enable_extension end def test_respond_to_disable_extension - assert @conn.respond_to?(:disable_extension) + assert_respond_to @conn, :disable_extension end def test_statement_closed @@ -414,6 +500,43 @@ module ActiveRecord end end + def test_deprecate_valid_alter_table_type + assert_deprecated { @conn.valid_alter_table_type?(:string) } + end + + def test_db_is_not_readonly_when_readonly_option_is_false + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3", + readonly: false + + assert_not_predicate conn.raw_connection, :readonly? + end + + def test_db_is_not_readonly_when_readonly_option_is_unspecified + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3" + + assert_not_predicate conn.raw_connection, :readonly? + end + + def test_db_is_readonly_when_readonly_option_is_true + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3", + readonly: true + + assert_predicate conn.raw_connection, :readonly? + end + + def test_writes_are_not_permitted_to_readonly_databases + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3", + readonly: true + + assert_raises(ActiveRecord::StatementInvalid, /SQLite3::ReadOnlyException/) do + conn.execute("CREATE TABLE test(id integer)") + end + end + private def assert_logged(logs) diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb index b1b4463bf1..d70486605f 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/owner" diff --git a/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb index 37ff973397..61002435a4 100644 --- a/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class SQLite3StatementPoolTest < ActiveRecord::SQLite3TestCase @@ -8,7 +10,7 @@ class SQLite3StatementPoolTest < ActiveRecord::SQLite3TestCase assert_equal "bar", cache["foo"] pid = fork { - lookup = cache["foo"]; + lookup = cache["foo"] exit!(!lookup) } diff --git a/activerecord/test/cases/aggregations_test.rb b/activerecord/test/cases/aggregations_test.rb index f8136fde72..fbdf2ada4b 100644 --- a/activerecord/test/cases/aggregations_test.rb +++ b/activerecord/test/cases/aggregations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/customer" @@ -25,7 +27,7 @@ class AggregationsTest < ActiveRecord::TestCase def test_immutable_value_objects customers(:david).balance = Money.new(100) - assert_raise(RuntimeError) { customers(:david).balance.instance_eval { @amount = 20 } } + assert_raise(frozen_error_class) { customers(:david).balance.instance_eval { @amount = 20 } } end def test_inferred_mapping diff --git a/activerecord/test/cases/ar_schema_test.rb b/activerecord/test/cases/ar_schema_test.rb index 5b608d8e83..f05dcac7dd 100644 --- a/activerecord/test/cases/ar_schema_test.rb +++ b/activerecord/test/cases/ar_schema_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class ActiveRecordSchemaTest < ActiveRecord::TestCase @@ -46,7 +48,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase assert_nothing_raised { @connection.select_all "SELECT * FROM fruits" } assert_nothing_raised { @connection.select_all "SELECT * FROM schema_migrations" } - assert_equal 7, ActiveRecord::Migrator::current_version + assert_equal 7, @connection.migration_context.current_version end def test_schema_define_w_table_name_prefix @@ -62,7 +64,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase t.column :flavor, :string end end - assert_equal 7, ActiveRecord::Migrator::current_version + assert_equal 7, @connection.migration_context.current_version ensure ActiveRecord::Base.table_name_prefix = old_table_name_prefix ActiveRecord::SchemaMigration.table_name = table_name @@ -114,8 +116,8 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase end end - assert !@connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null - assert !@connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null end def test_timestamps_without_null_set_null_to_false_on_change_table @@ -127,8 +129,8 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase end end - assert !@connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null - assert !@connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null end def test_timestamps_without_null_set_null_to_false_on_add_timestamps @@ -137,7 +139,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase add_timestamps :has_timestamps, default: Time.now end - assert !@connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null - assert !@connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null end end diff --git a/activerecord/test/cases/arel/attributes/attribute_test.rb b/activerecord/test/cases/arel/attributes/attribute_test.rb new file mode 100644 index 0000000000..671e273543 --- /dev/null +++ b/activerecord/test/cases/arel/attributes/attribute_test.rb @@ -0,0 +1,1015 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "ostruct" + +module Arel + module Attributes + class AttributeTest < Arel::Spec + describe "#not_eq" do + it "should create a NotEqual node" do + relation = Table.new(:users) + relation[:id].not_eq(10).must_be_kind_of Nodes::NotEqual + end + + it "should generate != in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" != 10 + } + end + + it "should handle nil" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq(nil) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" IS NOT NULL + } + end + end + + describe "#not_eq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_eq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" != 1 OR "users"."id" != 2) + } + end + end + + describe "#not_eq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_eq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" != 1 AND "users"."id" != 2) + } + end + end + + describe "#gt" do + it "should create a GreaterThan node" do + relation = Table.new(:users) + relation[:id].gt(10).must_be_kind_of Nodes::GreaterThan + end + + it "should generate > in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gt(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" > 10 + } + end + + it "should handle comparing with a subquery" do + users = Table.new(:users) + + avg = users.project(users[:karma].average) + mgr = users.project(Arel.star).where(users[:karma].gt(avg)) + + mgr.to_sql.must_be_like %{ + SELECT * FROM "users" WHERE "users"."karma" > (SELECT AVG("users"."karma") FROM "users") + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].gt("fake_name") + mgr.to_sql.must_match %{"users"."name" > 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].gt(current_time) + mgr.to_sql.must_match %{"users"."created_at" > '#{current_time}'} + end + end + + describe "#gt_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gt_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gt_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" > 1 OR "users"."id" > 2) + } + end + end + + describe "#gt_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gt_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gt_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" > 1 AND "users"."id" > 2) + } + end + end + + describe "#gteq" do + it "should create a GreaterThanOrEqual node" do + relation = Table.new(:users) + relation[:id].gteq(10).must_be_kind_of Nodes::GreaterThanOrEqual + end + + it "should generate >= in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gteq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" >= 10 + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].gteq("fake_name") + mgr.to_sql.must_match %{"users"."name" >= 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].gteq(current_time) + mgr.to_sql.must_match %{"users"."created_at" >= '#{current_time}'} + end + end + + describe "#gteq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gteq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gteq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 1 OR "users"."id" >= 2) + } + end + end + + describe "#gteq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gteq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gteq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 1 AND "users"."id" >= 2) + } + end + end + + describe "#lt" do + it "should create a LessThan node" do + relation = Table.new(:users) + relation[:id].lt(10).must_be_kind_of Nodes::LessThan + end + + it "should generate < in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lt(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" < 10 + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].lt("fake_name") + mgr.to_sql.must_match %{"users"."name" < 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].lt(current_time) + mgr.to_sql.must_match %{"users"."created_at" < '#{current_time}'} + end + end + + describe "#lt_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lt_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lt_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" < 1 OR "users"."id" < 2) + } + end + end + + describe "#lt_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lt_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lt_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" < 1 AND "users"."id" < 2) + } + end + end + + describe "#lteq" do + it "should create a LessThanOrEqual node" do + relation = Table.new(:users) + relation[:id].lteq(10).must_be_kind_of Nodes::LessThanOrEqual + end + + it "should generate <= in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lteq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" <= 10 + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].lteq("fake_name") + mgr.to_sql.must_match %{"users"."name" <= 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].lteq(current_time) + mgr.to_sql.must_match %{"users"."created_at" <= '#{current_time}'} + end + end + + describe "#lteq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lteq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lteq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" <= 1 OR "users"."id" <= 2) + } + end + end + + describe "#lteq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lteq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lteq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" <= 1 AND "users"."id" <= 2) + } + end + end + + describe "#average" do + it "should create a AVG node" do + relation = Table.new(:users) + relation[:id].average.must_be_kind_of Nodes::Avg + end + + it "should generate the proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].average + mgr.to_sql.must_be_like %{ + SELECT AVG("users"."id") + FROM "users" + } + end + end + + describe "#maximum" do + it "should create a MAX node" do + relation = Table.new(:users) + relation[:id].maximum.must_be_kind_of Nodes::Max + end + + it "should generate proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].maximum + mgr.to_sql.must_be_like %{ + SELECT MAX("users"."id") + FROM "users" + } + end + end + + describe "#minimum" do + it "should create a Min node" do + relation = Table.new(:users) + relation[:id].minimum.must_be_kind_of Nodes::Min + end + + it "should generate proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].minimum + mgr.to_sql.must_be_like %{ + SELECT MIN("users"."id") + FROM "users" + } + end + end + + describe "#sum" do + it "should create a SUM node" do + relation = Table.new(:users) + relation[:id].sum.must_be_kind_of Nodes::Sum + end + + it "should generate the proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].sum + mgr.to_sql.must_be_like %{ + SELECT SUM("users"."id") + FROM "users" + } + end + end + + describe "#count" do + it "should return a count node" do + relation = Table.new(:users) + relation[:id].count.must_be_kind_of Nodes::Count + end + + it "should take a distinct param" do + relation = Table.new(:users) + count = relation[:id].count(nil) + count.must_be_kind_of Nodes::Count + count.distinct.must_be_nil + end + end + + describe "#eq" do + it "should return an equality node" do + attribute = Attribute.new nil, nil + equality = attribute.eq 1 + equality.left.must_equal attribute + equality.right.val.must_equal 1 + equality.must_be_kind_of Nodes::Equality + end + + it "should generate = in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" = 10 + } + end + + it "should handle nil" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq(nil) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" IS NULL + } + end + end + + describe "#eq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].eq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 OR "users"."id" = 2) + } + end + + it "should not eat input" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + values = [1, 2] + mgr.where relation[:id].eq_any(values) + values.must_equal [1, 2] + end + end + + describe "#eq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].eq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 AND "users"."id" = 2) + } + end + + it "should not eat input" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + values = [1, 2] + mgr.where relation[:id].eq_all(values) + values.must_equal [1, 2] + end + end + + describe "#matches" do + it "should create a Matches node" do + relation = Table.new(:users) + relation[:name].matches("%bacon%").must_be_kind_of Nodes::Matches + end + + it "should generate LIKE in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].matches("%bacon%") + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."name" LIKE '%bacon%' + } + end + end + + describe "#matches_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].matches_any(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].matches_any(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" LIKE '%chunky%' OR "users"."name" LIKE '%bacon%') + } + end + end + + describe "#matches_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].matches_all(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].matches_all(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" LIKE '%chunky%' AND "users"."name" LIKE '%bacon%') + } + end + end + + describe "#does_not_match" do + it "should create a DoesNotMatch node" do + relation = Table.new(:users) + relation[:name].does_not_match("%bacon%").must_be_kind_of Nodes::DoesNotMatch + end + + it "should generate NOT LIKE in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match("%bacon%") + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."name" NOT LIKE '%bacon%' + } + end + end + + describe "#does_not_match_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].does_not_match_any(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_any(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" NOT LIKE '%chunky%' OR "users"."name" NOT LIKE '%bacon%') + } + end + end + + describe "#does_not_match_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].does_not_match_all(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" NOT LIKE '%chunky%' AND "users"."name" NOT LIKE '%bacon%') + } + end + end + + describe "with a range" do + it "can be constructed with a standard range" do + attribute = Attribute.new nil, nil + node = attribute.between(1..3) + + node.must_equal Nodes::Between.new( + attribute, + Nodes::And.new([ + Nodes::Casted.new(1, attribute), + Nodes::Casted.new(3, attribute) + ]) + ) + end + + it "can be constructed with a range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(-::Float::INFINITY..3) + + node.must_equal Nodes::LessThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with a quoted range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(-::Float::INFINITY, 3, false)) + + node.must_equal Nodes::LessThanOrEqual.new( + attribute, + Nodes::Quoted.new(3) + ) + end + + it "can be constructed with an exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(-::Float::INFINITY...3) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with a quoted exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(-::Float::INFINITY, 3, true)) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Quoted.new(3) + ) + end + + it "can be constructed with an infinite range" do + attribute = Attribute.new nil, nil + node = attribute.between(-::Float::INFINITY..::Float::INFINITY) + + node.must_equal Nodes::NotIn.new(attribute, []) + end + + it "can be constructed with a quoted infinite range" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(-::Float::INFINITY, ::Float::INFINITY, false)) + + node.must_equal Nodes::NotIn.new(attribute, []) + end + + + it "can be constructed with a range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(0..::Float::INFINITY) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + + it "can be constructed with a quoted range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(0, ::Float::INFINITY, false)) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Quoted.new(0) + ) + end + + it "can be constructed with an exclusive range" do + attribute = Attribute.new nil, nil + node = attribute.between(0...3) + + node.must_equal Nodes::And.new([ + Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(0, attribute) + ), + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + ]) + end + + def quoted_range(begin_val, end_val, exclude) + OpenStruct.new( + begin: Nodes::Quoted.new(begin_val), + end: Nodes::Quoted.new(end_val), + exclude_end?: exclude, + ) + end + end + + describe "#in" do + it "can be constructed with a subquery" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"]) + attribute = Attribute.new nil, nil + + node = attribute.in(mgr) + + node.must_equal Nodes::In.new(attribute, mgr.ast) + end + + it "can be constructed with a list" do + attribute = Attribute.new nil, nil + node = attribute.in([1, 2, 3]) + + node.must_equal Nodes::In.new( + attribute, + [ + Nodes::Casted.new(1, attribute), + Nodes::Casted.new(2, attribute), + Nodes::Casted.new(3, attribute), + ] + ) + end + + it "can be constructed with a random object" do + attribute = Attribute.new nil, nil + random_object = Object.new + node = attribute.in(random_object) + + node.must_equal Nodes::In.new( + attribute, + Nodes::Casted.new(random_object, attribute) + ) + end + + it "should generate IN in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].in([1, 2, 3]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" IN (1, 2, 3) + } + end + end + + describe "#in_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].in_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].in_any([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" IN (1, 2) OR "users"."id" IN (3, 4)) + } + end + end + + describe "#in_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].in_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].in_all([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" IN (1, 2) AND "users"."id" IN (3, 4)) + } + end + end + + describe "with a range" do + it "can be constructed with a standard range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(1..3) + + node.must_equal Nodes::Grouping.new(Nodes::Or.new( + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(1, attribute) + ), + Nodes::GreaterThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + )) + end + + it "can be constructed with a range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(-::Float::INFINITY..3) + + node.must_equal Nodes::GreaterThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with an exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(-::Float::INFINITY...3) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with an infinite range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(-::Float::INFINITY..::Float::INFINITY) + + node.must_equal Nodes::In.new(attribute, []) + end + + it "can be constructed with a range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(0..::Float::INFINITY) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + + it "can be constructed with an exclusive range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(0...3) + + node.must_equal Nodes::Grouping.new(Nodes::Or.new( + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ), + Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + )) + end + end + + describe "#not_in" do + it "can be constructed with a subquery" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"]) + attribute = Attribute.new nil, nil + + node = attribute.not_in(mgr) + + node.must_equal Nodes::NotIn.new(attribute, mgr.ast) + end + + it "can be constructed with a Union" do + relation = Table.new(:users) + mgr1 = relation.project(relation[:id]) + mgr2 = relation.project(relation[:id]) + + union = mgr1.union(mgr2) + node = relation[:id].in(union) + node.to_sql.must_be_like %{ + "users"."id" IN (( SELECT "users"."id" FROM "users" UNION SELECT "users"."id" FROM "users" )) + } + end + + it "can be constructed with a list" do + attribute = Attribute.new nil, nil + node = attribute.not_in([1, 2, 3]) + + node.must_equal Nodes::NotIn.new( + attribute, + [ + Nodes::Casted.new(1, attribute), + Nodes::Casted.new(2, attribute), + Nodes::Casted.new(3, attribute), + ] + ) + end + + it "can be constructed with a random object" do + attribute = Attribute.new nil, nil + random_object = Object.new + node = attribute.not_in(random_object) + + node.must_equal Nodes::NotIn.new( + attribute, + Nodes::Casted.new(random_object, attribute) + ) + end + + it "should generate NOT IN in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_in([1, 2, 3]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" NOT IN (1, 2, 3) + } + end + end + + describe "#not_in_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_in_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_in_any([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" NOT IN (1, 2) OR "users"."id" NOT IN (3, 4)) + } + end + end + + describe "#not_in_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_in_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_in_all([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" NOT IN (1, 2) AND "users"."id" NOT IN (3, 4)) + } + end + end + + describe "#eq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].eq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 AND "users"."id" = 2) + } + end + end + + describe "#asc" do + it "should create an Ascending node" do + relation = Table.new(:users) + relation[:id].asc.must_be_kind_of Nodes::Ascending + end + + it "should generate ASC in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.order relation[:id].asc + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC + } + end + end + + describe "#desc" do + it "should create a Descending node" do + relation = Table.new(:users) + relation[:id].desc.must_be_kind_of Nodes::Descending + end + + it "should generate DESC in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.order relation[:id].desc + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" ORDER BY "users"."id" DESC + } + end + end + + describe "equality" do + describe "#to_sql" do + it "should produce sql" do + table = Table.new :users + condition = table["id"].eq 1 + condition.to_sql.must_equal '"users"."id" = 1' + end + end + end + + describe "type casting" do + it "does not type cast by default" do + table = Table.new(:foo) + condition = table["id"].eq("1") + + assert_not table.able_to_type_cast? + condition.to_sql.must_equal %("foo"."id" = '1') + end + + it "type casts when given an explicit caster" do + fake_caster = Object.new + def fake_caster.type_cast_for_database(attr_name, value) + if attr_name == "id" + value.to_i + else + value + end + end + table = Table.new(:foo, type_caster: fake_caster) + condition = table["id"].eq("1").and(table["other_id"].eq("2")) + + assert table.able_to_type_cast? + condition.to_sql.must_equal %("foo"."id" = 1 AND "foo"."other_id" = '2') + end + + it "does not type cast SqlLiteral nodes" do + fake_caster = Object.new + def fake_caster.type_cast_for_database(attr_name, value) + value.to_i + end + table = Table.new(:foo, type_caster: fake_caster) + condition = table["id"].eq(Arel.sql("(select 1)")) + + assert table.able_to_type_cast? + condition.to_sql.must_equal %("foo"."id" = (select 1)) + end + end + end + end +end diff --git a/activerecord/test/cases/arel/attributes/math_test.rb b/activerecord/test/cases/arel/attributes/math_test.rb new file mode 100644 index 0000000000..41eea217c0 --- /dev/null +++ b/activerecord/test/cases/arel/attributes/math_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Attributes + class MathTest < Arel::Spec + %i[* /].each do |math_operator| + it "average should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].average.public_send(math_operator, 2)).to_sql.must_be_like %{ + AVG("users"."id") #{math_operator} 2 + } + end + + it "count should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].count.public_send(math_operator, 2)).to_sql.must_be_like %{ + COUNT("users"."id") #{math_operator} 2 + } + end + + it "maximum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].maximum.public_send(math_operator, 2)).to_sql.must_be_like %{ + MAX("users"."id") #{math_operator} 2 + } + end + + it "minimum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].minimum.public_send(math_operator, 2)).to_sql.must_be_like %{ + MIN("users"."id") #{math_operator} 2 + } + end + + it "attribute node should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].public_send(math_operator, 2)).to_sql.must_be_like %{ + "users"."id" #{math_operator} 2 + } + end + end + + %i[+ - & | ^ << >>].each do |math_operator| + it "average should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].average.public_send(math_operator, 2)).to_sql.must_be_like %{ + (AVG("users"."id") #{math_operator} 2) + } + end + + it "count should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].count.public_send(math_operator, 2)).to_sql.must_be_like %{ + (COUNT("users"."id") #{math_operator} 2) + } + end + + it "maximum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].maximum.public_send(math_operator, 2)).to_sql.must_be_like %{ + (MAX("users"."id") #{math_operator} 2) + } + end + + it "minimum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].minimum.public_send(math_operator, 2)).to_sql.must_be_like %{ + (MIN("users"."id") #{math_operator} 2) + } + end + + it "attribute node should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].public_send(math_operator, 2)).to_sql.must_be_like %{ + ("users"."id" #{math_operator} 2) + } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/attributes_test.rb b/activerecord/test/cases/arel/attributes_test.rb new file mode 100644 index 0000000000..b00af4bd29 --- /dev/null +++ b/activerecord/test/cases/arel/attributes_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + describe "Attributes" do + it "responds to lower" do + relation = Table.new(:users) + attribute = relation[:foo] + node = attribute.lower + assert_equal "LOWER", node.name + assert_equal [attribute], node.expressions + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Attribute.new("foo", "bar"), Attribute.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Attribute.new("foo", "bar"), Attribute.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + + describe "for" do + it "deals with unknown column types" do + column = Struct.new(:type).new :crazy + Attributes.for(column).must_equal Attributes::Undefined + end + + it "returns the correct constant for strings" do + [:string, :text, :binary].each do |type| + column = Struct.new(:type).new type + Attributes.for(column).must_equal Attributes::String + end + end + + it "returns the correct constant for ints" do + column = Struct.new(:type).new :integer + Attributes.for(column).must_equal Attributes::Integer + end + + it "returns the correct constant for floats" do + column = Struct.new(:type).new :float + Attributes.for(column).must_equal Attributes::Float + end + + it "returns the correct constant for decimals" do + column = Struct.new(:type).new :decimal + Attributes.for(column).must_equal Attributes::Decimal + end + + it "returns the correct constant for boolean" do + column = Struct.new(:type).new :boolean + Attributes.for(column).must_equal Attributes::Boolean + end + + it "returns the correct constant for time" do + [:date, :datetime, :timestamp, :time].each do |type| + column = Struct.new(:type).new type + Attributes.for(column).must_equal Attributes::Time + end + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/bind_test.rb b/activerecord/test/cases/arel/collectors/bind_test.rb new file mode 100644 index 0000000000..ffa9b15f66 --- /dev/null +++ b/activerecord/test/cases/arel/collectors/bind_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "arel/collectors/bind" + +module Arel + module Collectors + class TestBind < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def collect(node) + @visitor.accept(node, Collectors::Bind.new) + end + + def compile(node) + collect(node).value + end + + def ast_with_binds(bvs) + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new(bvs.shift))) + manager.where(table[:name].eq(Nodes::BindParam.new(bvs.shift))) + manager.ast + end + + def test_compile_gathers_all_bind_params + binds = compile(ast_with_binds(["hello", "world"])) + assert_equal ["hello", "world"], binds + + binds = compile(ast_with_binds(["hello2", "world3"])) + assert_equal ["hello2", "world3"], binds + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/composite_test.rb b/activerecord/test/cases/arel/collectors/composite_test.rb new file mode 100644 index 0000000000..545637496f --- /dev/null +++ b/activerecord/test/cases/arel/collectors/composite_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../helper" + +require "arel/collectors/bind" +require "arel/collectors/composite" + +module Arel + module Collectors + class TestComposite < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def collect(node) + sql_collector = Collectors::SQLString.new + bind_collector = Collectors::Bind.new + collector = Collectors::Composite.new(sql_collector, bind_collector) + @visitor.accept(node, collector) + end + + def compile(node) + collect(node).value + end + + def ast_with_binds(bvs) + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new(bvs.shift))) + manager.where(table[:name].eq(Nodes::BindParam.new(bvs.shift))) + manager.ast + end + + def test_composite_collector_performs_multiple_collections_at_once + sql, binds = compile(ast_with_binds(["hello", "world"])) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + assert_equal ["hello", "world"], binds + + sql, binds = compile(ast_with_binds(["hello2", "world3"])) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + assert_equal ["hello2", "world3"], binds + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/sql_string_test.rb b/activerecord/test/cases/arel/collectors/sql_string_test.rb new file mode 100644 index 0000000000..380573494d --- /dev/null +++ b/activerecord/test/cases/arel/collectors/sql_string_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Collectors + class TestSqlString < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def collect(node) + @visitor.accept(node, Collectors::SQLString.new) + end + + def compile(node) + collect(node).value + end + + def ast_with_binds(bv) + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(bv)) + manager.where(table[:name].eq(bv)) + manager.ast + end + + def test_compile + bv = Nodes::BindParam.new(1) + collector = collect ast_with_binds bv + + sql = collector.compile ["hello", "world"] + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + end + + def test_returned_sql_uses_utf8_encoding + bv = Nodes::BindParam.new(1) + collector = collect ast_with_binds bv + + sql = collector.compile ["hello", "world"] + assert_equal sql.encoding, Encoding::UTF_8 + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb b/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb new file mode 100644 index 0000000000..255c8e79e9 --- /dev/null +++ b/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "arel/collectors/substitute_binds" +require "arel/collectors/sql_string" + +module Arel + module Collectors + class TestSubstituteBindCollector < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def ast_with_binds + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new("hello"))) + manager.where(table[:name].eq(Nodes::BindParam.new("world"))) + manager.ast + end + + def compile(node, quoter) + collector = Collectors::SubstituteBinds.new(quoter, Collectors::SQLString.new) + @visitor.accept(node, collector).value + end + + def test_compile + quoter = Object.new + def quoter.quote(val) + val.to_s + end + sql = compile(ast_with_binds, quoter) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = hello AND "users"."name" = world', sql + end + + def test_quoting_is_delegated_to_quoter + quoter = Object.new + def quoter.quote(val) + val.inspect + end + sql = compile(ast_with_binds, quoter) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = "hello" AND "users"."name" = "world"', sql + end + end + end +end diff --git a/activerecord/test/cases/arel/crud_test.rb b/activerecord/test/cases/arel/crud_test.rb new file mode 100644 index 0000000000..f3cdd8927f --- /dev/null +++ b/activerecord/test/cases/arel/crud_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class FakeCrudder < SelectManager + class FakeEngine + attr_reader :calls, :connection_pool, :spec, :config + + def initialize + @calls = [] + @connection_pool = self + @spec = self + @config = { adapter: "sqlite3" } + end + + def connection; self end + + def method_missing(name, *args) + @calls << [name, args] + end + end + + include Crud + + attr_reader :engine + attr_accessor :ctx + + def initialize(engine = FakeEngine.new) + super + end + end + + describe "crud" do + describe "insert" do + it "should call insert on the connection" do + table = Table.new :users + fc = FakeCrudder.new + fc.from table + im = fc.compile_insert [[table[:id], "foo"]] + assert_instance_of Arel::InsertManager, im + end + end + + describe "update" do + it "should call update on the connection" do + table = Table.new :users + fc = FakeCrudder.new + fc.from table + stmt = fc.compile_update [[table[:id], "foo"]], Arel::Attributes::Attribute.new(table, "id") + assert_instance_of Arel::UpdateManager, stmt + end + end + + describe "delete" do + it "should call delete on the connection" do + table = Table.new :users + fc = FakeCrudder.new + fc.from table + stmt = fc.compile_delete + assert_instance_of Arel::DeleteManager, stmt + end + end + end +end diff --git a/activerecord/test/cases/arel/delete_manager_test.rb b/activerecord/test/cases/arel/delete_manager_test.rb new file mode 100644 index 0000000000..17a5271039 --- /dev/null +++ b/activerecord/test/cases/arel/delete_manager_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class DeleteManagerTest < Arel::Spec + describe "new" do + it "takes an engine" do + Arel::DeleteManager.new + end + end + + it "handles limit properly" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.take 10 + dm.from table + assert_match(/LIMIT 10/, dm.to_sql) + end + + describe "from" do + it "uses from" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.from table + dm.to_sql.must_be_like %{ DELETE FROM "users" } + end + + it "chains" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.from(table).must_equal dm + end + end + + describe "where" do + it "uses where values" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.from table + dm.where table[:id].eq(10) + dm.to_sql.must_be_like %{ DELETE FROM "users" WHERE "users"."id" = 10} + end + + it "chains" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.where(table[:id].eq(10)).must_equal dm + end + end + end +end diff --git a/activerecord/test/cases/arel/factory_methods_test.rb b/activerecord/test/cases/arel/factory_methods_test.rb new file mode 100644 index 0000000000..26d2cdd08d --- /dev/null +++ b/activerecord/test/cases/arel/factory_methods_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + module FactoryMethods + class TestFactoryMethods < Arel::Test + class Factory + include Arel::FactoryMethods + end + + def setup + @factory = Factory.new + end + + def test_create_join + join = @factory.create_join :one, :two + assert_kind_of Nodes::Join, join + assert_equal :two, join.right + end + + def test_create_on + on = @factory.create_on :one + assert_instance_of Nodes::On, on + assert_equal :one, on.expr + end + + def test_create_true + true_node = @factory.create_true + assert_instance_of Nodes::True, true_node + end + + def test_create_false + false_node = @factory.create_false + assert_instance_of Nodes::False, false_node + end + + def test_lower + lower = @factory.lower :one + assert_instance_of Nodes::NamedFunction, lower + assert_equal "LOWER", lower.name + assert_equal [:one], lower.expressions.map(&:expr) + end + end + end +end diff --git a/activerecord/test/cases/arel/helper.rb b/activerecord/test/cases/arel/helper.rb new file mode 100644 index 0000000000..f8ce658440 --- /dev/null +++ b/activerecord/test/cases/arel/helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "active_support" +require "minitest/autorun" +require "arel" + +require_relative "support/fake_record" + +class Object + def must_be_like(other) + gsub(/\s+/, " ").strip.must_equal other.gsub(/\s+/, " ").strip + end +end + +module Arel + class Test < ActiveSupport::TestCase + def setup + super + @arel_engine = Arel::Table.engine + Arel::Table.engine = FakeRecord::Base.new + end + + def teardown + Arel::Table.engine = @arel_engine if defined? @arel_engine + super + end + end + + class Spec < Minitest::Spec + before do + @arel_engine = Arel::Table.engine + Arel::Table.engine = FakeRecord::Base.new + end + + after do + Arel::Table.engine = @arel_engine if defined? @arel_engine + end + include ActiveSupport::Testing::Assertions + + # test/unit backwards compatibility methods + alias :assert_no_match :refute_match + alias :assert_not_equal :refute_equal + alias :assert_not_same :refute_same + end +end diff --git a/activerecord/test/cases/arel/insert_manager_test.rb b/activerecord/test/cases/arel/insert_manager_test.rb new file mode 100644 index 0000000000..2376ad8d37 --- /dev/null +++ b/activerecord/test/cases/arel/insert_manager_test.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class InsertManagerTest < Arel::Spec + describe "new" do + it "takes an engine" do + Arel::InsertManager.new + end + end + + describe "insert" do + it "can create a Values node" do + manager = Arel::InsertManager.new + values = manager.create_values %w{ a b }, %w{ c d } + + assert_kind_of Arel::Nodes::Values, values + assert_equal %w{ a b }, values.left + assert_equal %w{ c d }, values.right + end + + it "allows sql literals" do + manager = Arel::InsertManager.new + manager.into Table.new(:users) + manager.values = manager.create_values [Arel.sql("*")], %w{ a } + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" VALUES (*) + } + end + + it "works with multiple values" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + + manager.columns << table[:id] + manager.columns << table[:name] + + manager.values = manager.create_values_list([ + %w{1 david}, + %w{2 kir}, + ["3", Arel.sql("DEFAULT")], + ]) + + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" (\"id\", \"name\") VALUES ('1', 'david'), ('2', 'kir'), ('3', DEFAULT) + } + end + + it "literals in multiple values are not escaped" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + + manager.columns << table[:name] + + manager.values = manager.create_values_list([ + [Arel.sql("*")], + [Arel.sql("DEFAULT")], + ]) + + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" (\"name\") VALUES (*), (DEFAULT) + } + end + + it "works with multiple single values" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + + manager.columns << table[:name] + + manager.values = manager.create_values_list([ + %w{david}, + %w{kir}, + [Arel.sql("DEFAULT")], + ]) + + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" (\"name\") VALUES ('david'), ('kir'), (DEFAULT) + } + end + + it "inserts false" do + table = Table.new(:users) + manager = Arel::InsertManager.new + + manager.insert [[table[:bool], false]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("bool") VALUES ('f') + } + end + + it "inserts null" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.insert [[table[:id], nil]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id") VALUES (NULL) + } + end + + it "inserts time" do + table = Table.new(:users) + manager = Arel::InsertManager.new + + time = Time.now + attribute = table[:created_at] + + manager.insert [[attribute, time]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("created_at") VALUES (#{Table.engine.connection.quote time}) + } + end + + it "takes a list of lists" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + manager.insert [[table[:id], 1], [table[:name], "aaron"]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + } + end + + it "defaults the table" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.insert [[table[:id], 1], [table[:name], "aaron"]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + } + end + + it "noop for empty list" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.insert [[table[:id], 1]] + manager.insert [] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id") VALUES (1) + } + end + + it "is chainable" do + table = Table.new(:users) + manager = Arel::InsertManager.new + insert_result = manager.insert [[table[:id], 1]] + assert_equal manager, insert_result + end + end + + describe "into" do + it "takes a Table and chains" do + manager = Arel::InsertManager.new + manager.into(Table.new(:users)).must_equal manager + end + + it "converts to sql" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + manager.to_sql.must_be_like %{ + INSERT INTO "users" + } + end + end + + describe "columns" do + it "converts to sql" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + manager.columns << table[:id] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id") + } + end + end + + describe "values" do + it "converts to sql" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + + manager.values = Nodes::Values.new [1] + manager.to_sql.must_be_like %{ + INSERT INTO "users" VALUES (1) + } + end + + it "accepts sql literals" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + + manager.values = Arel.sql("DEFAULT VALUES") + manager.to_sql.must_be_like %{ + INSERT INTO "users" DEFAULT VALUES + } + end + end + + describe "combo" do + it "combines columns and values list in order" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + + manager.values = Nodes::Values.new [1, "aaron"] + manager.columns << table[:id] + manager.columns << table[:name] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + } + end + end + + describe "select" do + it "accepts a select query in place of a VALUES clause" do + table = Table.new :users + + manager = Arel::InsertManager.new + manager.into table + + select = Arel::SelectManager.new + select.project Arel.sql("1") + select.project Arel.sql('"aaron"') + + manager.select select + manager.columns << table[:id] + manager.columns << table[:name] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") (SELECT 1, "aaron") + } + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/and_test.rb b/activerecord/test/cases/arel/nodes/and_test.rb new file mode 100644 index 0000000000..eff54abd91 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/and_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "And" do + describe "equality" do + it "is equal with equal ivars" do + array = [And.new(["foo", "bar"]), And.new(["foo", "bar"])] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [And.new(["foo", "bar"]), And.new(["foo", "baz"])] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/as_test.rb b/activerecord/test/cases/arel/nodes/as_test.rb new file mode 100644 index 0000000000..1169ea11c9 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/as_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "As" do + describe "#as" do + it "makes an AS node" do + attr = Table.new(:users)[:id] + as = attr.as(Arel.sql("foo")) + assert_equal attr, as.left + assert_equal "foo", as.right + end + + it "converts right to SqlLiteral if a string" do + attr = Table.new(:users)[:id] + as = attr.as("foo") + assert_kind_of Arel::Nodes::SqlLiteral, as.right + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [As.new("foo", "bar"), As.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [As.new("foo", "bar"), As.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/ascending_test.rb b/activerecord/test/cases/arel/nodes/ascending_test.rb new file mode 100644 index 0000000000..4811e6ff5b --- /dev/null +++ b/activerecord/test/cases/arel/nodes/ascending_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestAscending < Arel::Test + def test_construct + ascending = Ascending.new "zomg" + assert_equal "zomg", ascending.expr + end + + def test_reverse + ascending = Ascending.new "zomg" + descending = ascending.reverse + assert_kind_of Descending, descending + assert_equal ascending.expr, descending.expr + end + + def test_direction + ascending = Ascending.new "zomg" + assert_equal :asc, ascending.direction + end + + def test_ascending? + ascending = Ascending.new "zomg" + assert ascending.ascending? + end + + def test_descending? + ascending = Ascending.new "zomg" + assert_not ascending.descending? + end + + def test_equality_with_same_ivars + array = [Ascending.new("zomg"), Ascending.new("zomg")] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [Ascending.new("zomg"), Ascending.new("zomg!")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/bin_test.rb b/activerecord/test/cases/arel/nodes/bin_test.rb new file mode 100644 index 0000000000..ee2ec3cf2f --- /dev/null +++ b/activerecord/test/cases/arel/nodes/bin_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestBin < Arel::Test + def test_new + assert Arel::Nodes::Bin.new("zomg") + end + + def test_default_to_sql + viz = Arel::Visitors::ToSql.new Table.engine.connection_pool + node = Arel::Nodes::Bin.new(Arel.sql("zomg")) + assert_equal "zomg", viz.accept(node, Collectors::SQLString.new).value + end + + def test_mysql_to_sql + viz = Arel::Visitors::MySQL.new Table.engine.connection_pool + node = Arel::Nodes::Bin.new(Arel.sql("zomg")) + assert_equal "BINARY zomg", viz.accept(node, Collectors::SQLString.new).value + end + + def test_equality_with_same_ivars + array = [Bin.new("zomg"), Bin.new("zomg")] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [Bin.new("zomg"), Bin.new("zomg!")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/binary_test.rb b/activerecord/test/cases/arel/nodes/binary_test.rb new file mode 100644 index 0000000000..d160e7cd9d --- /dev/null +++ b/activerecord/test/cases/arel/nodes/binary_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class NodesTest < Arel::Spec + describe "Binary" do + describe "#hash" do + it "generates a hash based on its value" do + eq = Equality.new("foo", "bar") + eq2 = Equality.new("foo", "bar") + eq3 = Equality.new("bar", "baz") + + assert_equal eq.hash, eq2.hash + assert_not_equal eq.hash, eq3.hash + end + + it "generates a hash specific to its class" do + eq = Equality.new("foo", "bar") + neq = NotEqual.new("foo", "bar") + + assert_not_equal eq.hash, neq.hash + end + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/bind_param_test.rb b/activerecord/test/cases/arel/nodes/bind_param_test.rb new file mode 100644 index 0000000000..37a362ece4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/bind_param_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "BindParam" do + it "is equal to other bind params with the same value" do + BindParam.new(1).must_equal(BindParam.new(1)) + BindParam.new("foo").must_equal(BindParam.new("foo")) + end + + it "is not equal to other nodes" do + BindParam.new(nil).wont_equal(Node.new) + end + + it "is not equal to bind params with different values" do + BindParam.new(1).wont_equal(BindParam.new(2)) + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/case_test.rb b/activerecord/test/cases/arel/nodes/case_test.rb new file mode 100644 index 0000000000..89861488df --- /dev/null +++ b/activerecord/test/cases/arel/nodes/case_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class NodesTest < Arel::Spec + describe "Case" do + describe "#initialize" do + it "sets case expression from first argument" do + node = Case.new "foo" + + assert_equal "foo", node.case + end + + it "sets default case from second argument" do + node = Case.new nil, "bar" + + assert_equal "bar", node.default + end + end + + describe "#clone" do + it "clones case, conditions and default" do + foo = Nodes.build_quoted "foo" + + node = Case.new + node.case = foo + node.conditions = [When.new(foo, foo)] + node.default = foo + + dolly = node.clone + + assert_equal dolly.case, node.case + assert_not_same dolly.case, node.case + + assert_equal dolly.conditions, node.conditions + assert_not_same dolly.conditions, node.conditions + + assert_equal dolly.default, node.default + assert_not_same dolly.default, node.default + end + end + + describe "equality" do + it "is equal with equal ivars" do + foo = Nodes.build_quoted "foo" + one = Nodes.build_quoted 1 + zero = Nodes.build_quoted 0 + + case1 = Case.new foo + case1.conditions = [When.new(foo, one)] + case1.default = Else.new zero + + case2 = Case.new foo + case2.conditions = [When.new(foo, one)] + case2.default = Else.new zero + + array = [case1, case2] + + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + foo = Nodes.build_quoted "foo" + bar = Nodes.build_quoted "bar" + one = Nodes.build_quoted 1 + zero = Nodes.build_quoted 0 + + case1 = Case.new foo + case1.conditions = [When.new(foo, one)] + case1.default = Else.new zero + + case2 = Case.new foo + case2.conditions = [When.new(bar, one)] + case2.default = Else.new zero + + array = [case1, case2] + + assert_equal 2, array.uniq.size + end + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/casted_test.rb b/activerecord/test/cases/arel/nodes/casted_test.rb new file mode 100644 index 0000000000..e27f58a4e2 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/casted_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe Casted do + describe "#hash" do + it "is equal when eql? returns true" do + one = Casted.new 1, 2 + also_one = Casted.new 1, 2 + + assert_equal one.hash, also_one.hash + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/count_test.rb b/activerecord/test/cases/arel/nodes/count_test.rb new file mode 100644 index 0000000000..daabea6c4c --- /dev/null +++ b/activerecord/test/cases/arel/nodes/count_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::CountTest < Arel::Spec + describe "as" do + it "should alias the count" do + table = Arel::Table.new :users + table[:id].count.as("foo").to_sql.must_be_like %{ + COUNT("users"."id") AS foo + } + end + end + + describe "eq" do + it "should compare the count" do + table = Arel::Table.new :users + table[:id].count.eq(2).to_sql.must_be_like %{ + COUNT("users"."id") = 2 + } + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Arel::Nodes::Count.new("foo"), Arel::Nodes::Count.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Arel::Nodes::Count.new("foo"), Arel::Nodes::Count.new("foo!")] + assert_equal 2, array.uniq.size + 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 new file mode 100644 index 0000000000..3f078063a4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/delete_statement_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::DeleteStatement do + describe "#clone" do + it "clones wheres" do + statement = Arel::Nodes::DeleteStatement.new + statement.wheres = %w[a b c] + + dolly = statement.clone + dolly.wheres.must_equal statement.wheres + dolly.wheres.wont_be_same_as statement.wheres + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::DeleteStatement.new + statement1.wheres = %w[a b c] + statement2 = Arel::Nodes::DeleteStatement.new + statement2.wheres = %w[a b c] + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::DeleteStatement.new + statement1.wheres = %w[a b c] + statement2 = Arel::Nodes::DeleteStatement.new + statement2.wheres = %w[1 2 3] + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/descending_test.rb b/activerecord/test/cases/arel/nodes/descending_test.rb new file mode 100644 index 0000000000..5f1747e1da --- /dev/null +++ b/activerecord/test/cases/arel/nodes/descending_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestDescending < Arel::Test + def test_construct + descending = Descending.new "zomg" + assert_equal "zomg", descending.expr + end + + def test_reverse + descending = Descending.new "zomg" + ascending = descending.reverse + assert_kind_of Ascending, ascending + assert_equal descending.expr, ascending.expr + end + + def test_direction + descending = Descending.new "zomg" + assert_equal :desc, descending.direction + end + + def test_ascending? + descending = Descending.new "zomg" + assert_not descending.ascending? + end + + def test_descending? + descending = Descending.new "zomg" + assert descending.descending? + end + + def test_equality_with_same_ivars + array = [Descending.new("zomg"), Descending.new("zomg")] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [Descending.new("zomg"), Descending.new("zomg!")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/distinct_test.rb b/activerecord/test/cases/arel/nodes/distinct_test.rb new file mode 100644 index 0000000000..de5f0ee588 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/distinct_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "Distinct" do + describe "equality" do + it "is equal to other distinct nodes" do + array = [Distinct.new, Distinct.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [Distinct.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/equality_test.rb b/activerecord/test/cases/arel/nodes/equality_test.rb new file mode 100644 index 0000000000..e173720e86 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/equality_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "equality" do + # FIXME: backwards compat + describe "backwards compat" do + describe "operator" do + it "returns :==" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + left.operator.must_equal :== + end + end + + describe "operand1" do + it "should equal left" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + left.left.must_equal left.operand1 + end + end + + describe "operand2" do + it "should equal right" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + left.right.must_equal left.operand2 + end + end + + describe "to_sql" do + it "takes an engine" do + engine = FakeRecord::Base.new + engine.connection.extend Module.new { + attr_accessor :quote_count + def quote(*args) @quote_count += 1; super; end + def quote_column_name(*args) @quote_count += 1; super; end + def quote_table_name(*args) @quote_count += 1; super; end + } + engine.connection.quote_count = 0 + + attr = Table.new(:users)[:id] + test = attr.eq(10) + test.to_sql engine + engine.connection.quote_count.must_equal 3 + end + end + end + + describe "or" do + it "makes an OR node" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + right = attr.eq(11) + node = left.or right + node.expr.left.must_equal left + node.expr.right.must_equal right + end + end + + describe "and" do + it "makes and AND node" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + right = attr.eq(11) + node = left.and right + node.left.must_equal left + node.right.must_equal right + end + end + + it "is equal with equal ivars" do + array = [Equality.new("foo", "bar"), Equality.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Equality.new("foo", "bar"), Equality.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/extract_test.rb b/activerecord/test/cases/arel/nodes/extract_test.rb new file mode 100644 index 0000000000..8fc1e04d67 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/extract_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::ExtractTest < Arel::Spec + it "should extract field" do + table = Arel::Table.new :users + table[:timestamp].extract("date").to_sql.must_be_like %{ + EXTRACT(DATE FROM "users"."timestamp") + } + end + + describe "as" do + it "should alias the extract" do + table = Arel::Table.new :users + table[:timestamp].extract("date").as("foo").to_sql.must_be_like %{ + EXTRACT(DATE FROM "users"."timestamp") AS foo + } + end + + it "should not mutate the extract" do + table = Arel::Table.new :users + extract = table[:timestamp].extract("date") + before = extract.dup + extract.as("foo") + assert_equal extract, before + end + end + + describe "equality" do + it "is equal with equal ivars" do + table = Arel::Table.new :users + array = [table[:attr].extract("foo"), table[:attr].extract("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + table = Arel::Table.new :users + array = [table[:attr].extract("foo"), table[:attr].extract("bar")] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/false_test.rb b/activerecord/test/cases/arel/nodes/false_test.rb new file mode 100644 index 0000000000..4ecf8e332e --- /dev/null +++ b/activerecord/test/cases/arel/nodes/false_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "False" do + describe "equality" do + it "is equal to other false nodes" do + array = [False.new, False.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [False.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/grouping_test.rb b/activerecord/test/cases/arel/nodes/grouping_test.rb new file mode 100644 index 0000000000..03d5c142d5 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/grouping_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class GroupingTest < Arel::Spec + it "should create Equality nodes" do + grouping = Grouping.new(Nodes.build_quoted("foo")) + grouping.eq("foo").to_sql.must_be_like "('foo') = 'foo'" + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Grouping.new("foo"), Grouping.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Grouping.new("foo"), Grouping.new("bar")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/infix_operation_test.rb b/activerecord/test/cases/arel/nodes/infix_operation_test.rb new file mode 100644 index 0000000000..dcf2200c12 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/infix_operation_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestInfixOperation < Arel::Test + def test_construct + operation = InfixOperation.new :+, 1, 2 + assert_equal :+, operation.operator + assert_equal 1, operation.left + assert_equal 2, operation.right + end + + def test_operation_alias + operation = InfixOperation.new :+, 1, 2 + aliaz = operation.as("zomg") + assert_kind_of As, aliaz + assert_equal operation, aliaz.left + assert_equal "zomg", aliaz.right + end + + def test_operation_ordering + operation = InfixOperation.new :+, 1, 2 + ordering = operation.desc + assert_kind_of Descending, ordering + assert_equal operation, ordering.expr + assert ordering.descending? + end + + def test_equality_with_same_ivars + array = [InfixOperation.new(:+, 1, 2), InfixOperation.new(:+, 1, 2)] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [InfixOperation.new(:+, 1, 2), InfixOperation.new(:+, 1, 3)] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/insert_statement_test.rb b/activerecord/test/cases/arel/nodes/insert_statement_test.rb new file mode 100644 index 0000000000..252a0d0d0b --- /dev/null +++ b/activerecord/test/cases/arel/nodes/insert_statement_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::InsertStatement do + describe "#clone" do + it "clones columns and values" do + statement = Arel::Nodes::InsertStatement.new + statement.columns = %w[a b c] + statement.values = %w[x y z] + + dolly = statement.clone + dolly.columns.must_equal statement.columns + dolly.values.must_equal statement.values + + dolly.columns.wont_be_same_as statement.columns + dolly.values.wont_be_same_as statement.values + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::InsertStatement.new + statement1.columns = %w[a b c] + statement1.values = %w[x y z] + statement2 = Arel::Nodes::InsertStatement.new + statement2.columns = %w[a b c] + statement2.values = %w[x y z] + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::InsertStatement.new + statement1.columns = %w[a b c] + statement1.values = %w[x y z] + statement2 = Arel::Nodes::InsertStatement.new + statement2.columns = %w[a b c] + statement2.values = %w[1 2 3] + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/named_function_test.rb b/activerecord/test/cases/arel/nodes/named_function_test.rb new file mode 100644 index 0000000000..dbd7ae43be --- /dev/null +++ b/activerecord/test/cases/arel/nodes/named_function_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestNamedFunction < Arel::Test + def test_construct + function = NamedFunction.new "omg", "zomg" + assert_equal "omg", function.name + assert_equal "zomg", function.expressions + end + + def test_function_alias + function = NamedFunction.new "omg", "zomg" + function = function.as("wth") + assert_equal "omg", function.name + assert_equal "zomg", function.expressions + assert_kind_of SqlLiteral, function.alias + assert_equal "wth", function.alias + end + + def test_construct_with_alias + function = NamedFunction.new "omg", "zomg", "wth" + assert_equal "omg", function.name + assert_equal "zomg", function.expressions + assert_kind_of SqlLiteral, function.alias + assert_equal "wth", function.alias + end + + def test_equality_with_same_ivars + array = [ + NamedFunction.new("omg", "zomg", "wth"), + NamedFunction.new("omg", "zomg", "wth") + ] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [ + NamedFunction.new("omg", "zomg", "wth"), + NamedFunction.new("zomg", "zomg", "wth") + ] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/node_test.rb b/activerecord/test/cases/arel/nodes/node_test.rb new file mode 100644 index 0000000000..f4f07ef2c5 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/node_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + class TestNode < Arel::Test + def test_includes_factory_methods + assert Node.new.respond_to?(:create_join) + end + + def test_all_nodes_are_nodes + Nodes.constants.map { |k| + Nodes.const_get(k) + }.grep(Class).each do |klass| + next if Nodes::SqlLiteral == klass + next if Nodes::BindParam == klass + next if klass.name =~ /^Arel::Nodes::(?:Test|.*Test$)/ + assert klass.ancestors.include?(Nodes::Node), klass.name + end + end + + def test_each + list = [] + node = Nodes::Node.new + node.each { |n| list << n } + assert_equal [node], list + end + + def test_generator + list = [] + node = Nodes::Node.new + node.each.each { |n| list << n } + assert_equal [node], list + end + + def test_enumerable + node = Nodes::Node.new + assert_kind_of Enumerable, node + end + end +end diff --git a/activerecord/test/cases/arel/nodes/not_test.rb b/activerecord/test/cases/arel/nodes/not_test.rb new file mode 100644 index 0000000000..481e678700 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/not_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "not" do + describe "#not" do + it "makes a NOT node" do + attr = Table.new(:users)[:id] + expr = attr.eq(10) + node = expr.not + node.must_be_kind_of Not + node.expr.must_equal expr + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Not.new("foo"), Not.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Not.new("foo"), Not.new("baz")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/or_test.rb b/activerecord/test/cases/arel/nodes/or_test.rb new file mode 100644 index 0000000000..93f826740d --- /dev/null +++ b/activerecord/test/cases/arel/nodes/or_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "or" do + describe "#or" do + it "makes an OR node" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + right = attr.eq(11) + node = left.or right + node.expr.left.must_equal left + node.expr.right.must_equal right + + oror = node.or(right) + oror.expr.left.must_equal node + oror.expr.right.must_equal right + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Or.new("foo", "bar"), Or.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Or.new("foo", "bar"), Or.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/over_test.rb b/activerecord/test/cases/arel/nodes/over_test.rb new file mode 100644 index 0000000000..981ec2e34b --- /dev/null +++ b/activerecord/test/cases/arel/nodes/over_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::OverTest < Arel::Spec + describe "as" do + it "should alias the expression" do + table = Arel::Table.new :users + table[:id].count.over.as("foo").to_sql.must_be_like %{ + COUNT("users"."id") OVER () AS foo + } + end + end + + describe "with literal" do + it "should reference the window definition by name" do + table = Arel::Table.new :users + table[:id].count.over("foo").to_sql.must_be_like %{ + COUNT("users"."id") OVER "foo" + } + end + end + + describe "with SQL literal" do + it "should reference the window definition by name" do + table = Arel::Table.new :users + table[:id].count.over(Arel.sql("foo")).to_sql.must_be_like %{ + COUNT("users"."id") OVER foo + } + end + end + + describe "with no expression" do + it "should use empty definition" do + table = Arel::Table.new :users + table[:id].count.over.to_sql.must_be_like %{ + COUNT("users"."id") OVER () + } + end + end + + describe "with expression" do + it "should use definition in sub-expression" do + table = Arel::Table.new :users + window = Arel::Nodes::Window.new.order(table["foo"]) + table[:id].count.over(window).to_sql.must_be_like %{ + COUNT("users"."id") OVER (ORDER BY \"users\".\"foo\") + } + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [ + Arel::Nodes::Over.new("foo", "bar"), + Arel::Nodes::Over.new("foo", "bar") + ] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [ + Arel::Nodes::Over.new("foo", "bar"), + Arel::Nodes::Over.new("foo", "baz") + ] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/select_core_test.rb b/activerecord/test/cases/arel/nodes/select_core_test.rb new file mode 100644 index 0000000000..0b698205ff --- /dev/null +++ b/activerecord/test/cases/arel/nodes/select_core_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestSelectCore < Arel::Test + def test_clone + core = Arel::Nodes::SelectCore.new + core.froms = %w[a b c] + core.projections = %w[d e f] + core.wheres = %w[g h i] + + dolly = core.clone + + assert_equal core.froms, dolly.froms + assert_equal core.projections, dolly.projections + assert_equal core.wheres, dolly.wheres + + assert_not_same core.froms, dolly.froms + assert_not_same core.projections, dolly.projections + assert_not_same core.wheres, dolly.wheres + end + + def test_set_quantifier + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::Distinct.new + viz = Arel::Visitors::ToSql.new Table.engine.connection_pool + assert_match "DISTINCT", viz.accept(core, Collectors::SQLString.new).value + end + + def test_equality_with_same_ivars + core1 = SelectCore.new + core1.froms = %w[a b c] + core1.projections = %w[d e f] + core1.wheres = %w[g h i] + core1.groups = %w[j k l] + core1.windows = %w[m n o] + core1.havings = %w[p q r] + core2 = SelectCore.new + core2.froms = %w[a b c] + core2.projections = %w[d e f] + core2.wheres = %w[g h i] + core2.groups = %w[j k l] + core2.windows = %w[m n o] + core2.havings = %w[p q r] + array = [core1, core2] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + core1 = SelectCore.new + core1.froms = %w[a b c] + core1.projections = %w[d e f] + core1.wheres = %w[g h i] + core1.groups = %w[j k l] + core1.windows = %w[m n o] + core1.havings = %w[p q r] + core2 = SelectCore.new + core2.froms = %w[a b c] + core2.projections = %w[d e f] + core2.wheres = %w[g h i] + core2.groups = %w[j k l] + core2.windows = %w[m n o] + core2.havings = %w[l o l] + array = [core1, core2] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/select_statement_test.rb b/activerecord/test/cases/arel/nodes/select_statement_test.rb new file mode 100644 index 0000000000..a91605de3e --- /dev/null +++ b/activerecord/test/cases/arel/nodes/select_statement_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::SelectStatement do + describe "#clone" do + it "clones cores" do + statement = Arel::Nodes::SelectStatement.new %w[a b c] + + dolly = statement.clone + dolly.cores.must_equal statement.cores + dolly.cores.wont_be_same_as statement.cores + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::SelectStatement.new %w[a b c] + statement1.offset = 1 + statement1.limit = 2 + statement1.lock = false + statement1.orders = %w[x y z] + statement1.with = "zomg" + statement2 = Arel::Nodes::SelectStatement.new %w[a b c] + statement2.offset = 1 + statement2.limit = 2 + statement2.lock = false + statement2.orders = %w[x y z] + statement2.with = "zomg" + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::SelectStatement.new %w[a b c] + statement1.offset = 1 + statement1.limit = 2 + statement1.lock = false + statement1.orders = %w[x y z] + statement1.with = "zomg" + statement2 = Arel::Nodes::SelectStatement.new %w[a b c] + statement2.offset = 1 + statement2.limit = 2 + statement2.lock = false + statement2.orders = %w[x y z] + statement2.with = "wth" + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/sql_literal_test.rb b/activerecord/test/cases/arel/nodes/sql_literal_test.rb new file mode 100644 index 0000000000..3b95fed1f4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/sql_literal_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "yaml" + +module Arel + module Nodes + class SqlLiteralTest < Arel::Spec + before do + @visitor = Visitors::ToSql.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + describe "sql" do + it "makes a sql literal node" do + sql = Arel.sql "foo" + sql.must_be_kind_of Arel::Nodes::SqlLiteral + end + end + + describe "count" do + it "makes a count node" do + node = SqlLiteral.new("*").count + compile(node).must_be_like %{ COUNT(*) } + end + + it "makes a distinct node" do + node = SqlLiteral.new("*").count true + compile(node).must_be_like %{ COUNT(DISTINCT *) } + end + end + + describe "equality" do + it "makes an equality node" do + node = SqlLiteral.new("foo").eq(1) + compile(node).must_be_like %{ foo = 1 } + end + + it "is equal with equal contents" do + array = [SqlLiteral.new("foo"), SqlLiteral.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different contents" do + array = [SqlLiteral.new("foo"), SqlLiteral.new("bar")] + assert_equal 2, array.uniq.size + end + end + + describe 'grouped "or" equality' do + it "makes a grouping node with an or node" do + node = SqlLiteral.new("foo").eq_any([1, 2]) + compile(node).must_be_like %{ (foo = 1 OR foo = 2) } + end + end + + describe 'grouped "and" equality' do + it "makes a grouping node with an and node" do + node = SqlLiteral.new("foo").eq_all([1, 2]) + compile(node).must_be_like %{ (foo = 1 AND foo = 2) } + end + end + + describe "serialization" do + it "serializes into YAML" do + yaml_literal = SqlLiteral.new("foo").to_yaml + assert_equal("foo", YAML.load(yaml_literal)) + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/sum_test.rb b/activerecord/test/cases/arel/nodes/sum_test.rb new file mode 100644 index 0000000000..5015964951 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/sum_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::SumTest < Arel::Spec + describe "as" do + it "should alias the sum" do + table = Arel::Table.new :users + table[:id].sum.as("foo").to_sql.must_be_like %{ + SUM("users"."id") AS foo + } + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Arel::Nodes::Sum.new("foo"), Arel::Nodes::Sum.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Arel::Nodes::Sum.new("foo"), Arel::Nodes::Sum.new("foo!")] + assert_equal 2, array.uniq.size + end + end + + describe "order" do + it "should order the sum" do + table = Arel::Table.new :users + table[:id].sum.desc.to_sql.must_be_like %{ + SUM("users"."id") DESC + } + end + end +end diff --git a/activerecord/test/cases/arel/nodes/table_alias_test.rb b/activerecord/test/cases/arel/nodes/table_alias_test.rb new file mode 100644 index 0000000000..c661b6771e --- /dev/null +++ b/activerecord/test/cases/arel/nodes/table_alias_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "table alias" do + describe "equality" do + it "is equal with equal ivars" do + relation1 = Table.new(:users) + node1 = TableAlias.new relation1, :foo + relation2 = Table.new(:users) + node2 = TableAlias.new relation2, :foo + array = [node1, node2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + relation1 = Table.new(:users) + node1 = TableAlias.new relation1, :foo + relation2 = Table.new(:users) + node2 = TableAlias.new relation2, :bar + array = [node1, node2] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/true_test.rb b/activerecord/test/cases/arel/nodes/true_test.rb new file mode 100644 index 0000000000..1e85fe7d48 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/true_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "True" do + describe "equality" do + it "is equal to other true nodes" do + array = [True.new, True.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [True.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/unary_operation_test.rb b/activerecord/test/cases/arel/nodes/unary_operation_test.rb new file mode 100644 index 0000000000..f0dd0c625c --- /dev/null +++ b/activerecord/test/cases/arel/nodes/unary_operation_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestUnaryOperation < Arel::Test + def test_construct + operation = UnaryOperation.new :-, 1 + assert_equal :-, operation.operator + assert_equal 1, operation.expr + end + + def test_operation_alias + operation = UnaryOperation.new :-, 1 + aliaz = operation.as("zomg") + assert_kind_of As, aliaz + assert_equal operation, aliaz.left + assert_equal "zomg", aliaz.right + end + + def test_operation_ordering + operation = UnaryOperation.new :-, 1 + ordering = operation.desc + assert_kind_of Descending, ordering + assert_equal operation, ordering.expr + assert ordering.descending? + end + + def test_equality_with_same_ivars + array = [UnaryOperation.new(:-, 1), UnaryOperation.new(:-, 1)] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [UnaryOperation.new(:-, 1), UnaryOperation.new(:-, 2)] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/update_statement_test.rb b/activerecord/test/cases/arel/nodes/update_statement_test.rb new file mode 100644 index 0000000000..a83ce32f68 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/update_statement_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::UpdateStatement do + describe "#clone" do + it "clones wheres and values" do + statement = Arel::Nodes::UpdateStatement.new + statement.wheres = %w[a b c] + statement.values = %w[x y z] + + dolly = statement.clone + dolly.wheres.must_equal statement.wheres + dolly.wheres.wont_be_same_as statement.wheres + + dolly.values.must_equal statement.values + dolly.values.wont_be_same_as statement.values + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::UpdateStatement.new + statement1.relation = "zomg" + statement1.wheres = 2 + statement1.values = false + statement1.orders = %w[x y z] + statement1.limit = 42 + statement1.key = "zomg" + statement2 = Arel::Nodes::UpdateStatement.new + statement2.relation = "zomg" + statement2.wheres = 2 + statement2.values = false + statement2.orders = %w[x y z] + statement2.limit = 42 + statement2.key = "zomg" + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::UpdateStatement.new + statement1.relation = "zomg" + statement1.wheres = 2 + statement1.values = false + statement1.orders = %w[x y z] + statement1.limit = 42 + statement1.key = "zomg" + statement2 = Arel::Nodes::UpdateStatement.new + statement2.relation = "zomg" + statement2.wheres = 2 + statement2.values = false + statement2.orders = %w[x y z] + statement2.limit = 42 + statement2.key = "wth" + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/window_test.rb b/activerecord/test/cases/arel/nodes/window_test.rb new file mode 100644 index 0000000000..729b0556a4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/window_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "Window" do + describe "equality" do + it "is equal with equal ivars" do + window1 = Window.new + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = Window.new + window2.orders = [1, 2] + window2.partitions = [1] + window2.frame 3 + array = [window1, window2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + window1 = Window.new + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = Window.new + window2.orders = [1, 2] + window1.partitions = [1] + window2.frame 4 + array = [window1, window2] + assert_equal 2, array.uniq.size + end + end + end + + describe "NamedWindow" do + describe "equality" do + it "is equal with equal ivars" do + window1 = NamedWindow.new "foo" + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = NamedWindow.new "foo" + window2.orders = [1, 2] + window2.partitions = [1] + window2.frame 3 + array = [window1, window2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + window1 = NamedWindow.new "foo" + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = NamedWindow.new "bar" + window2.orders = [1, 2] + window2.partitions = [1] + window2.frame 3 + array = [window1, window2] + assert_equal 2, array.uniq.size + end + end + end + + describe "CurrentRow" do + describe "equality" do + it "is equal to other current row nodes" do + array = [CurrentRow.new, CurrentRow.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [CurrentRow.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes_test.rb b/activerecord/test/cases/arel/nodes_test.rb new file mode 100644 index 0000000000..9021de0d20 --- /dev/null +++ b/activerecord/test/cases/arel/nodes_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + module Nodes + class TestNodes < Arel::Test + def test_every_arel_nodes_have_hash_eql_eqeq_from_same_class + # #descendants code from activesupport + node_descendants = [] + ObjectSpace.each_object(Arel::Nodes::Node.singleton_class) do |k| + next if k.respond_to?(:singleton_class?) && k.singleton_class? + node_descendants.unshift k unless k == self + end + node_descendants.delete(Arel::Nodes::Node) + node_descendants.delete(Arel::Nodes::NodeExpression) + + bad_node_descendants = node_descendants.reject do |subnode| + eqeq_owner = subnode.instance_method(:==).owner + eql_owner = subnode.instance_method(:eql?).owner + hash_owner = subnode.instance_method(:hash).owner + + eqeq_owner < Arel::Nodes::Node && + eqeq_owner == eql_owner && + eqeq_owner == hash_owner + end + + problem_msg = "Some subclasses of Arel::Nodes::Node do not have a" \ + " #== or #eql? or #hash defined from the same class as the others" + assert_empty bad_node_descendants, problem_msg + end + end + end +end diff --git a/activerecord/test/cases/arel/select_manager_test.rb b/activerecord/test/cases/arel/select_manager_test.rb new file mode 100644 index 0000000000..c17487ae88 --- /dev/null +++ b/activerecord/test/cases/arel/select_manager_test.rb @@ -0,0 +1,1225 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class SelectManagerTest < Arel::Spec + def test_join_sources + manager = Arel::SelectManager.new + manager.join_sources << Arel::Nodes::StringJoin.new(Nodes.build_quoted("foo")) + assert_equal "SELECT FROM 'foo'", manager.to_sql + end + + describe "backwards compatibility" do + describe "project" do + it "accepts symbols as sql literals" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project :id + manager.from table + manager.to_sql.must_be_like %{ + SELECT id FROM "users" + } + end + end + + describe "order" do + it "accepts symbols" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order :foo + manager.to_sql.must_be_like %{ SELECT * FROM "users" ORDER BY foo } + end + end + + describe "group" do + it "takes a symbol" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group :foo + manager.to_sql.must_be_like %{ SELECT FROM "users" GROUP BY foo } + end + end + + describe "as" do + it "makes an AS node by grouping the AST" do + manager = Arel::SelectManager.new + as = manager.as(Arel.sql("foo")) + assert_kind_of Arel::Nodes::Grouping, as.left + assert_equal manager.ast, as.left.expr + assert_equal "foo", as.right + end + + it "converts right to SqlLiteral if a string" do + manager = Arel::SelectManager.new + as = manager.as("foo") + assert_kind_of Arel::Nodes::SqlLiteral, as.right + end + + it "can make a subselect" do + manager = Arel::SelectManager.new + manager.project Arel.star + manager.from Arel.sql("zomg") + as = manager.as(Arel.sql("foo")) + + manager = Arel::SelectManager.new + manager.project Arel.sql("name") + manager.from as + manager.to_sql.must_be_like "SELECT name FROM (SELECT * FROM zomg) foo" + end + end + + describe "from" do + it "ignores strings when table of same name exists" do + table = Table.new :users + manager = Arel::SelectManager.new + + manager.from table + manager.from "users" + manager.project table["id"] + manager.to_sql.must_be_like 'SELECT "users"."id" FROM users' + end + + it "should support any ast" do + table = Table.new :users + manager1 = Arel::SelectManager.new + + manager2 = Arel::SelectManager.new + manager2.project(Arel.sql("*")) + manager2.from table + + manager1.project Arel.sql("lol") + as = manager2.as Arel.sql("omg") + manager1.from(as) + + manager1.to_sql.must_be_like %{ + SELECT lol FROM (SELECT * FROM "users") omg + } + end + end + + describe "having" do + it "converts strings to SQLLiterals" do + table = Table.new :users + mgr = table.from + mgr.having Arel.sql("foo") + mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo } + end + + it "can have multiple items specified separately" do + table = Table.new :users + mgr = table.from + mgr.having Arel.sql("foo") + mgr.having Arel.sql("bar") + mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo AND bar } + end + + it "can receive any node" do + table = Table.new :users + mgr = table.from + mgr.having Arel::Nodes::And.new([Arel.sql("foo"), Arel.sql("bar")]) + mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo AND bar } + end + end + + describe "on" do + it "converts to sqlliterals" do + table = Table.new :users + right = table.alias + mgr = table.from + mgr.join(right).on("omg") + mgr.to_sql.must_be_like %{ SELECT FROM "users" INNER JOIN "users" "users_2" ON omg } + end + + it "converts to sqlliterals with multiple items" do + table = Table.new :users + right = table.alias + mgr = table.from + mgr.join(right).on("omg", "123") + mgr.to_sql.must_be_like %{ SELECT FROM "users" INNER JOIN "users" "users_2" ON omg AND 123 } + end + end + end + + describe "clone" do + it "creates new cores" do + table = Table.new :users, as: "foo" + mgr = table.from + m2 = mgr.clone + m2.project "foo" + mgr.to_sql.wont_equal m2.to_sql + end + + it "makes updates to the correct copy" do + table = Table.new :users, as: "foo" + mgr = table.from + m2 = mgr.clone + m3 = m2.clone + m2.project "foo" + mgr.to_sql.wont_equal m2.to_sql + m3.to_sql.must_equal mgr.to_sql + end + end + + describe "initialize" do + it "uses alias in sql" do + table = Table.new :users, as: "foo" + mgr = table.from + mgr.skip 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" "foo" OFFSET 10 } + end + end + + describe "skip" do + it "should add an offset" do + table = Table.new :users + mgr = table.from + mgr.skip 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + end + + it "should chain" do + table = Table.new :users + mgr = table.from + mgr.skip(10).to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + end + end + + describe "offset" do + it "should add an offset" do + table = Table.new :users + mgr = table.from + mgr.offset = 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + end + + it "should remove an offset" do + table = Table.new :users + mgr = table.from + mgr.offset = 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + + mgr.offset = nil + mgr.to_sql.must_be_like %{ SELECT FROM "users" } + end + + it "should return the offset" do + table = Table.new :users + mgr = table.from + mgr.offset = 10 + assert_equal 10, mgr.offset + end + end + + describe "exists" do + it "should create an exists clause" do + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.project Nodes::SqlLiteral.new "*" + m2 = Arel::SelectManager.new + m2.project manager.exists + m2.to_sql.must_be_like %{ SELECT EXISTS (#{manager.to_sql}) } + end + + it "can be aliased" do + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.project Nodes::SqlLiteral.new "*" + m2 = Arel::SelectManager.new + m2.project manager.exists.as("foo") + m2.to_sql.must_be_like %{ SELECT EXISTS (#{manager.to_sql}) AS foo } + end + end + + describe "union" do + before do + table = Table.new :users + @m1 = Arel::SelectManager.new table + @m1.project Arel.star + @m1.where(table[:age].lt(18)) + + @m2 = Arel::SelectManager.new table + @m2.project Arel.star + @m2.where(table[:age].gt(99)) + end + + it "should union two managers" do + # FIXME should this union "managers" or "statements" ? + # FIXME this probably shouldn't return a node + node = @m1.union @m2 + + # maybe FIXME: decide when wrapper parens are needed + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" < 18 UNION SELECT * FROM "users" WHERE "users"."age" > 99 ) + } + end + + it "should union all" do + node = @m1.union :all, @m2 + + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" < 18 UNION ALL SELECT * FROM "users" WHERE "users"."age" > 99 ) + } + end + end + + describe "intersect" do + before do + table = Table.new :users + @m1 = Arel::SelectManager.new table + @m1.project Arel.star + @m1.where(table[:age].gt(18)) + + @m2 = Arel::SelectManager.new table + @m2.project Arel.star + @m2.where(table[:age].lt(99)) + end + + it "should intersect two managers" do + # FIXME should this intersect "managers" or "statements" ? + # FIXME this probably shouldn't return a node + node = @m1.intersect @m2 + + # maybe FIXME: decide when wrapper parens are needed + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" > 18 INTERSECT SELECT * FROM "users" WHERE "users"."age" < 99 ) + } + end + end + + describe "except" do + before do + table = Table.new :users + @m1 = Arel::SelectManager.new table + @m1.project Arel.star + @m1.where(table[:age].between(18..60)) + + @m2 = Arel::SelectManager.new table + @m2.project Arel.star + @m2.where(table[:age].between(40..99)) + end + + it "should except two managers" do + # FIXME should this except "managers" or "statements" ? + # FIXME this probably shouldn't return a node + node = @m1.except @m2 + + # maybe FIXME: decide when wrapper parens are needed + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" BETWEEN 18 AND 60 EXCEPT SELECT * FROM "users" WHERE "users"."age" BETWEEN 40 AND 99 ) + } + end + end + + describe "with" do + it "should support basic WITH" do + users = Table.new(:users) + users_top = Table.new(:users_top) + comments = Table.new(:comments) + + top = users.project(users[:id]).where(users[:karma].gt(100)) + users_as = Arel::Nodes::As.new(users_top, top) + select_manager = comments.project(Arel.star).with(users_as) + .where(comments[:author_id].in(users_top.project(users_top[:id]))) + + select_manager.to_sql.must_be_like %{ + WITH "users_top" AS (SELECT "users"."id" FROM "users" WHERE "users"."karma" > 100) SELECT * FROM "comments" WHERE "comments"."author_id" IN (SELECT "users_top"."id" FROM "users_top") + } + end + + it "should support WITH RECURSIVE" do + comments = Table.new(:comments) + comments_id = comments[:id] + comments_parent_id = comments[:parent_id] + + replies = Table.new(:replies) + replies_id = replies[:id] + + recursive_term = Arel::SelectManager.new + recursive_term.from(comments).project(comments_id, comments_parent_id).where(comments_id.eq 42) + + non_recursive_term = Arel::SelectManager.new + non_recursive_term.from(comments).project(comments_id, comments_parent_id).join(replies).on(comments_parent_id.eq replies_id) + + union = recursive_term.union(non_recursive_term) + + as_statement = Arel::Nodes::As.new replies, union + + manager = Arel::SelectManager.new + manager.with(:recursive, as_statement).from(replies).project(Arel.star) + + sql = manager.to_sql + sql.must_be_like %{ + WITH RECURSIVE "replies" AS ( + SELECT "comments"."id", "comments"."parent_id" FROM "comments" WHERE "comments"."id" = 42 + UNION + SELECT "comments"."id", "comments"."parent_id" FROM "comments" INNER JOIN "replies" ON "comments"."parent_id" = "replies"."id" + ) + SELECT * FROM "replies" + } + end + end + + describe "ast" do + it "should return the ast" do + table = Table.new :users + mgr = table.from + assert mgr.ast + end + + it "should allow orders to work when the ast is grepped" do + table = Table.new :users + mgr = table.from + mgr.project Arel.sql "*" + mgr.from table + mgr.orders << Arel::Nodes::Ascending.new(Arel.sql("foo")) + mgr.ast.grep(Arel::Nodes::OuterJoin) + mgr.to_sql.must_be_like %{ SELECT * FROM "users" ORDER BY foo ASC } + end + end + + describe "taken" do + it "should return limit" do + manager = Arel::SelectManager.new + manager.take 10 + manager.taken.must_equal 10 + end + end + + describe "lock" do + # This should fail on other databases + it "adds a lock node" do + table = Table.new :users + mgr = table.from + mgr.lock.to_sql.must_be_like %{ SELECT FROM "users" FOR UPDATE } + end + end + + describe "orders" do + it "returns order clauses" do + table = Table.new :users + manager = Arel::SelectManager.new + order = table[:id] + manager.order table[:id] + manager.orders.must_equal [order] + end + end + + describe "order" do + it "generates order clauses" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order table[:id] + manager.to_sql.must_be_like %{ + SELECT * FROM "users" ORDER BY "users"."id" + } + end + + # FIXME: I would like to deprecate this + it "takes *args" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order table[:id], table[:name] + manager.to_sql.must_be_like %{ + SELECT * FROM "users" ORDER BY "users"."id", "users"."name" + } + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.order(table[:id]).must_equal manager + end + + it "has order attributes" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order table[:id].desc + manager.to_sql.must_be_like %{ + SELECT * FROM "users" ORDER BY "users"."id" DESC + } + end + end + + describe "on" do + it "takes two params" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right).on(predicate, predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" AND + "users"."id" = "users_2"."id" + } + end + + it "takes three params" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right).on( + predicate, + predicate, + left[:name].eq(right[:name]) + ) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" AND + "users"."id" = "users_2"."id" AND + "users"."name" = "users_2"."name" + } + end + end + + it "should hand back froms" do + relation = Arel::SelectManager.new + assert_equal [], relation.froms + end + + it "should create and nodes" do + relation = Arel::SelectManager.new + children = ["foo", "bar", "baz"] + clause = relation.create_and children + assert_kind_of Arel::Nodes::And, clause + assert_equal children, clause.children + end + + it "should create insert managers" do + relation = Arel::SelectManager.new + insert = relation.create_insert + assert_kind_of Arel::InsertManager, insert + end + + it "should create join nodes" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar" + assert_kind_of Arel::Nodes::InnerJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a full outer join klass" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar", Arel::Nodes::FullOuterJoin + assert_kind_of Arel::Nodes::FullOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a outer join klass" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar", Arel::Nodes::OuterJoin + assert_kind_of Arel::Nodes::OuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a right outer join klass" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar", Arel::Nodes::RightOuterJoin + assert_kind_of Arel::Nodes::RightOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + describe "join" do + it "responds to join" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "takes a class" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right, Nodes::OuterJoin).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "takes the full outer join class" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right, Nodes::FullOuterJoin).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + FULL OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "takes the right outer join class" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right, Nodes::RightOuterJoin).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + RIGHT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "noops on nil" do + manager = Arel::SelectManager.new + manager.join(nil).must_equal manager + end + + it "raises EmptyJoinError on empty" do + left = Table.new :users + manager = Arel::SelectManager.new + + manager.from left + assert_raises(EmptyJoinError) do + manager.join("") + end + end + end + + describe "outer join" do + it "responds to join" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.outer_join(right).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "noops on nil" do + manager = Arel::SelectManager.new + manager.outer_join(nil).must_equal manager + end + end + + describe "joins" do + it "returns inner join sql" do + table = Table.new :users + aliaz = table.alias + manager = Arel::SelectManager.new + manager.from Nodes::InnerJoin.new(aliaz, table[:id].eq(aliaz[:id])) + assert_match 'INNER JOIN "users" "users_2" "users"."id" = "users_2"."id"', + manager.to_sql + end + + it "returns outer join sql" do + table = Table.new :users + aliaz = table.alias + manager = Arel::SelectManager.new + manager.from Nodes::OuterJoin.new(aliaz, table[:id].eq(aliaz[:id])) + assert_match 'LEFT OUTER JOIN "users" "users_2" "users"."id" = "users_2"."id"', + manager.to_sql + end + + it "can have a non-table alias as relation name" do + users = Table.new :users + comments = Table.new :comments + + counts = comments.from. + group(comments[:user_id]). + project( + comments[:user_id].as("user_id"), + comments[:user_id].count.as("count") + ).as("counts") + + joins = users.join(counts).on(counts[:user_id].eq(10)) + joins.to_sql.must_be_like %{ + SELECT FROM "users" INNER JOIN (SELECT "comments"."user_id" AS user_id, COUNT("comments"."user_id") AS count FROM "comments" GROUP BY "comments"."user_id") counts ON counts."user_id" = 10 + } + end + + it "joins itself" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + + mgr = left.join(right) + mgr.project Nodes::SqlLiteral.new("*") + mgr.on(predicate).must_equal mgr + + mgr.to_sql.must_be_like %{ + SELECT * FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "returns string join sql" do + manager = Arel::SelectManager.new + manager.from Nodes::StringJoin.new(Nodes.build_quoted("hello")) + assert_match "'hello'", manager.to_sql + end + end + + describe "group" do + it "takes an attribute" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group table[:id] + manager.to_sql.must_be_like %{ + SELECT FROM "users" GROUP BY "users"."id" + } + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.group(table[:id]).must_equal manager + end + + it "takes multiple args" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group table[:id], table[:name] + manager.to_sql.must_be_like %{ + SELECT FROM "users" GROUP BY "users"."id", "users"."name" + } + end + + # FIXME: backwards compat + it "makes strings literals" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group "foo" + manager.to_sql.must_be_like %{ SELECT FROM "users" GROUP BY foo } + end + end + + describe "window definition" do + it "can be empty" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window") + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS () + } + end + + it "takes an order" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").order(table["foo"].asc) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ORDER BY "users"."foo" ASC) + } + end + + it "takes an order with multiple columns" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").order(table["foo"].asc, table["bar"].desc) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ORDER BY "users"."foo" ASC, "users"."bar" DESC) + } + end + + it "takes a partition" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").partition(table["bar"]) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."bar") + } + end + + it "takes a partition and an order" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").partition(table["foo"]).order(table["foo"].asc) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."foo" + ORDER BY "users"."foo" ASC) + } + end + + it "takes a partition with multiple columns" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").partition(table["bar"], table["baz"]) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."bar", "users"."baz") + } + end + + it "takes a rows frame, unbounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Preceding.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS UNBOUNDED PRECEDING) + } + end + + it "takes a rows frame, bounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Preceding.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS 5 PRECEDING) + } + end + + it "takes a rows frame, unbounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Following.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS UNBOUNDED FOLLOWING) + } + end + + it "takes a rows frame, bounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Following.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS 5 FOLLOWING) + } + end + + it "takes a rows frame, current row" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::CurrentRow.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS CURRENT ROW) + } + end + + it "takes a rows frame, between two delimiters" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + window = manager.window("a_window") + window.frame( + Arel::Nodes::Between.new( + window.rows, + Nodes::And.new([ + Arel::Nodes::Preceding.new, + Arel::Nodes::CurrentRow.new + ]))) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) + } + end + + it "takes a range frame, unbounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Preceding.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE UNBOUNDED PRECEDING) + } + end + + it "takes a range frame, bounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Preceding.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE 5 PRECEDING) + } + end + + it "takes a range frame, unbounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Following.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE UNBOUNDED FOLLOWING) + } + end + + it "takes a range frame, bounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Following.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE 5 FOLLOWING) + } + end + + it "takes a range frame, current row" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::CurrentRow.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE CURRENT ROW) + } + end + + it "takes a range frame, between two delimiters" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + window = manager.window("a_window") + window.frame( + Arel::Nodes::Between.new( + window.range, + Nodes::And.new([ + Arel::Nodes::Preceding.new, + Arel::Nodes::CurrentRow.new + ]))) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) + } + end + end + + describe "delete" do + it "copies from" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + stmt = manager.compile_delete + + stmt.to_sql.must_be_like %{ DELETE FROM "users" } + end + + it "copies where" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + stmt = manager.compile_delete + + stmt.to_sql.must_be_like %{ + DELETE FROM "users" WHERE "users"."id" = 10 + } + end + end + + describe "where_sql" do + it "gives me back the where sql" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 } + end + + it "joins wheres with AND" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + manager.where table[:id].eq 11 + manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 AND "users"."id" = 11} + end + + it "handles database specific statements" do + old_visitor = Table.engine.connection.visitor + Table.engine.connection.visitor = Visitors::PostgreSQL.new Table.engine.connection + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + manager.where table[:name].matches "foo%" + manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 AND "users"."name" ILIKE 'foo%' } + Table.engine.connection.visitor = old_visitor + end + + it "returns nil when there are no wheres" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where_sql.must_be_nil + end + end + + describe "update" do + it "creates an update statement" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1 + } + end + + it "takes a string" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ UPDATE "users" SET foo = bar } + end + + it "copies limits" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.take 1 + stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id")) + stmt.key = table["id"] + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET foo = bar + WHERE "users"."id" IN (SELECT "users"."id" FROM "users" LIMIT 1) + } + end + + it "copies order" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.order :foo + stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id")) + stmt.key = table["id"] + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET foo = bar + WHERE "users"."id" IN (SELECT "users"."id" FROM "users" ORDER BY foo) + } + end + + it "copies where clauses" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.where table[:id].eq 10 + manager.from table + stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1 WHERE "users"."id" = 10 + } + end + + it "copies where clauses when nesting is triggered" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.where table[:foo].eq 10 + manager.take 42 + manager.from table + stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1 WHERE "users"."id" IN (SELECT "users"."id" FROM "users" WHERE "users"."foo" = 10 LIMIT 42) + } + end + end + + describe "project" do + it "takes sql literals" do + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.to_sql.must_be_like %{ SELECT * } + end + + it "takes multiple args" do + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new("foo"), + Nodes::SqlLiteral.new("bar") + manager.to_sql.must_be_like %{ SELECT foo, bar } + end + + it "takes strings" do + manager = Arel::SelectManager.new + manager.project "*" + manager.to_sql.must_be_like %{ SELECT * } + end + end + + describe "projections" do + it "reads projections" do + manager = Arel::SelectManager.new + manager.project Arel.sql("foo"), Arel.sql("bar") + manager.projections.must_equal [Arel.sql("foo"), Arel.sql("bar")] + end + end + + describe "projections=" do + it "overwrites projections" do + manager = Arel::SelectManager.new + manager.project Arel.sql("foo") + manager.projections = [Arel.sql("bar")] + manager.to_sql.must_be_like %{ SELECT bar } + end + end + + describe "take" do + it "knows take" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table).project(table["id"]) + manager.where(table["id"].eq(1)) + manager.take 1 + + manager.to_sql.must_be_like %{ + SELECT "users"."id" + FROM "users" + WHERE "users"."id" = 1 + LIMIT 1 + } + end + + it "chains" do + manager = Arel::SelectManager.new + manager.take(1).must_equal manager + end + + it "removes LIMIT when nil is passed" do + manager = Arel::SelectManager.new + manager.limit = 10 + assert_match("LIMIT", manager.to_sql) + + manager.limit = nil + assert_no_match("LIMIT", manager.to_sql) + end + end + + describe "where" do + it "knows where" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table).project(table["id"]) + manager.where(table["id"].eq(1)) + manager.to_sql.must_be_like %{ + SELECT "users"."id" + FROM "users" + WHERE "users"."id" = 1 + } + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table) + manager.project(table["id"]).where(table["id"].eq 1).must_equal manager + end + end + + describe "from" do + it "makes sql" do + table = Table.new :users + manager = Arel::SelectManager.new + + manager.from table + manager.project table["id"] + manager.to_sql.must_be_like 'SELECT "users"."id" FROM "users"' + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table).project(table["id"]).must_equal manager + manager.to_sql.must_be_like 'SELECT "users"."id" FROM "users"' + end + end + + describe "source" do + it "returns the join source of the select core" do + manager = Arel::SelectManager.new + manager.source.must_equal manager.ast.cores.last.source + end + end + + describe "distinct" do + it "sets the quantifier" do + manager = Arel::SelectManager.new + + manager.distinct + manager.ast.cores.last.set_quantifier.class.must_equal Arel::Nodes::Distinct + + manager.distinct(false) + manager.ast.cores.last.set_quantifier.must_be_nil + end + + it "chains" do + manager = Arel::SelectManager.new + manager.distinct.must_equal manager + manager.distinct(false).must_equal manager + end + end + + describe "distinct_on" do + it "sets the quantifier" do + manager = Arel::SelectManager.new + table = Table.new :users + + manager.distinct_on(table["id"]) + manager.ast.cores.last.set_quantifier.must_equal Arel::Nodes::DistinctOn.new(table["id"]) + + manager.distinct_on(false) + manager.ast.cores.last.set_quantifier.must_be_nil + end + + it "chains" do + manager = Arel::SelectManager.new + table = Table.new :users + + manager.distinct_on(table["id"]).must_equal manager + manager.distinct_on(false).must_equal manager + end + end + end +end diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb new file mode 100644 index 0000000000..559ff5d4e6 --- /dev/null +++ b/activerecord/test/cases/arel/support/fake_record.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "date" +module FakeRecord + class Column < Struct.new(:name, :type) + end + + class Connection + attr_reader :tables + attr_accessor :visitor + + def initialize(visitor = nil) + @tables = %w{ users photos developers products} + @columns = { + "users" => [ + Column.new("id", :integer), + Column.new("name", :string), + Column.new("bool", :boolean), + Column.new("created_at", :date) + ], + "products" => [ + Column.new("id", :integer), + Column.new("price", :decimal) + ] + } + @columns_hash = { + "users" => Hash[@columns["users"].map { |x| [x.name, x] }], + "products" => Hash[@columns["products"].map { |x| [x.name, x] }] + } + @primary_keys = { + "users" => "id", + "products" => "id" + } + @visitor = visitor + end + + def columns_hash(table_name) + @columns_hash[table_name] + end + + def primary_key(name) + @primary_keys[name.to_s] + end + + def data_source_exists?(name) + @tables.include? name.to_s + end + + def columns(name, message = nil) + @columns[name.to_s] + end + + def quote_table_name(name) + "\"#{name}\"" + end + + def quote_column_name(name) + "\"#{name}\"" + end + + def schema_cache + self + end + + def quote(thing) + case thing + when DateTime + "'#{thing.strftime("%Y-%m-%d %H:%M:%S")}'" + when Date + "'#{thing.strftime("%Y-%m-%d")}'" + when true + "'t'" + when false + "'f'" + when nil + "NULL" + when Numeric + thing + else + "'#{thing.to_s.gsub("'", "\\\\'")}'" + end + end + end + + class ConnectionPool + class Spec < Struct.new(:config) + end + + attr_reader :spec, :connection + + def initialize + @spec = Spec.new(adapter: "america") + @connection = Connection.new + @connection.visitor = Arel::Visitors::ToSql.new(connection) + end + + def with_connection + yield connection + end + + def table_exists?(name) + connection.tables.include? name.to_s + end + + def columns_hash + connection.columns_hash + end + + def schema_cache + connection + end + + def quote(thing) + connection.quote thing + end + end + + class Base + attr_accessor :connection_pool + + def initialize + @connection_pool = ConnectionPool.new + end + + def connection + connection_pool.connection + end + end +end diff --git a/activerecord/test/cases/arel/table_test.rb b/activerecord/test/cases/arel/table_test.rb new file mode 100644 index 0000000000..91b7a5a480 --- /dev/null +++ b/activerecord/test/cases/arel/table_test.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class TableTest < Arel::Spec + before do + @relation = Table.new(:users) + end + + it "should create join nodes" do + join = @relation.create_string_join "foo" + assert_kind_of Arel::Nodes::StringJoin, join + assert_equal "foo", join.left + end + + it "should create join nodes" do + join = @relation.create_join "foo", "bar" + assert_kind_of Arel::Nodes::InnerJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a klass" do + join = @relation.create_join "foo", "bar", Arel::Nodes::FullOuterJoin + assert_kind_of Arel::Nodes::FullOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a klass" do + join = @relation.create_join "foo", "bar", Arel::Nodes::OuterJoin + assert_kind_of Arel::Nodes::OuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a klass" do + join = @relation.create_join "foo", "bar", Arel::Nodes::RightOuterJoin + assert_kind_of Arel::Nodes::RightOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should return an insert manager" do + im = @relation.compile_insert "VALUES(NULL)" + assert_kind_of Arel::InsertManager, im + im.into Table.new(:users) + assert_equal "INSERT INTO \"users\" VALUES(NULL)", im.to_sql + end + + describe "skip" do + it "should add an offset" do + sm = @relation.skip 2 + sm.to_sql.must_be_like "SELECT FROM \"users\" OFFSET 2" + end + end + + describe "having" do + it "adds a having clause" do + mgr = @relation.having @relation[:id].eq(10) + mgr.to_sql.must_be_like %{ + SELECT FROM "users" HAVING "users"."id" = 10 + } + end + end + + describe "backwards compat" do + describe "join" do + it "noops on nil" do + mgr = @relation.join nil + + mgr.to_sql.must_be_like %{ SELECT FROM "users" } + end + + it "raises EmptyJoinError on empty" do + assert_raises(EmptyJoinError) do + @relation.join "" + end + end + + it "takes a second argument for join type" do + right = @relation.alias + predicate = @relation[:id].eq(right[:id]) + mgr = @relation.join(right, Nodes::OuterJoin).on(predicate) + + mgr.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + end + + describe "join" do + it "creates an outer join" do + right = @relation.alias + predicate = @relation[:id].eq(right[:id]) + mgr = @relation.outer_join(right).on(predicate) + + mgr.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + end + end + + describe "group" do + it "should create a group" do + manager = @relation.group @relation[:id] + manager.to_sql.must_be_like %{ + SELECT FROM "users" GROUP BY "users"."id" + } + end + end + + describe "alias" do + it "should create a node that proxies to a table" do + node = @relation.alias + node.name.must_equal "users_2" + node[:id].relation.must_equal node + end + end + + describe "new" do + it "should accept a hash" do + rel = Table.new :users, as: "foo" + rel.table_alias.must_equal "foo" + end + + it "ignores as if it equals name" do + rel = Table.new :users, as: "users" + rel.table_alias.must_be_nil + end + end + + describe "order" do + it "should take an order" do + manager = @relation.order "foo" + manager.to_sql.must_be_like %{ SELECT FROM "users" ORDER BY foo } + end + end + + describe "take" do + it "should add a limit" do + manager = @relation.take 1 + manager.project Nodes::SqlLiteral.new "*" + manager.to_sql.must_be_like %{ SELECT * FROM "users" LIMIT 1 } + end + end + + describe "project" do + it "can project" do + manager = @relation.project Nodes::SqlLiteral.new "*" + manager.to_sql.must_be_like %{ SELECT * FROM "users" } + end + + it "takes multiple parameters" do + manager = @relation.project Nodes::SqlLiteral.new("*"), Nodes::SqlLiteral.new("*") + manager.to_sql.must_be_like %{ SELECT *, * FROM "users" } + end + end + + describe "where" do + it "returns a tree manager" do + manager = @relation.where @relation[:id].eq 1 + manager.project @relation[:id] + manager.must_be_kind_of TreeManager + manager.to_sql.must_be_like %{ + SELECT "users"."id" + FROM "users" + WHERE "users"."id" = 1 + } + end + end + + it "should have a name" do + @relation.name.must_equal "users" + end + + it "should have a table name" do + @relation.table_name.must_equal "users" + end + + describe "[]" do + describe "when given a Symbol" do + it "manufactures an attribute if the symbol names an attribute within the relation" do + column = @relation[:id] + column.name.must_equal :id + end + end + end + + describe "equality" do + it "is equal with equal ivars" do + relation1 = Table.new(:users) + relation1.table_alias = "zomg" + relation2 = Table.new(:users) + relation2.table_alias = "zomg" + array = [relation1, relation2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + relation1 = Table.new(:users) + relation1.table_alias = "zomg" + relation2 = Table.new(:users) + relation2.table_alias = "zomg2" + array = [relation1, relation2] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/update_manager_test.rb b/activerecord/test/cases/arel/update_manager_test.rb new file mode 100644 index 0000000000..cc1b9ac5b3 --- /dev/null +++ b/activerecord/test/cases/arel/update_manager_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class UpdateManagerTest < Arel::Spec + describe "new" do + it "takes an engine" do + Arel::UpdateManager.new + end + end + + it "should not quote sql literals" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set [[table[:name], Arel::Nodes::BindParam.new(1)]] + um.to_sql.must_be_like %{ UPDATE "users" SET "name" = ? } + end + + it "handles limit properly" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.key = "id" + um.take 10 + um.table table + um.set [[table[:name], nil]] + assert_match(/LIMIT 10/, um.to_sql) + end + + describe "set" do + it "updates with null" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set [[table[:name], nil]] + um.to_sql.must_be_like %{ UPDATE "users" SET "name" = NULL } + end + + it "takes a string" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set Nodes::SqlLiteral.new "foo = bar" + um.to_sql.must_be_like %{ UPDATE "users" SET foo = bar } + end + + it "takes a list of lists" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set [[table[:id], 1], [table[:name], "hello"]] + um.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1, "name" = 'hello' + } + end + + it "chains" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.set([[table[:id], 1], [table[:name], "hello"]]).must_equal um + end + end + + describe "table" do + it "generates an update statement" do + um = Arel::UpdateManager.new + um.table Table.new(:users) + um.to_sql.must_be_like %{ UPDATE "users" } + end + + it "chains" do + um = Arel::UpdateManager.new + um.table(Table.new(:users)).must_equal um + end + + it "generates an update statement with joins" do + um = Arel::UpdateManager.new + + table = Table.new(:users) + join_source = Arel::Nodes::JoinSource.new( + table, + [table.create_join(Table.new(:posts))] + ) + + um.table join_source + um.to_sql.must_be_like %{ UPDATE "users" INNER JOIN "posts" } + end + end + + describe "where" do + it "generates a where clause" do + table = Table.new :users + um = Arel::UpdateManager.new + um.table table + um.where table[:id].eq(1) + um.to_sql.must_be_like %{ + UPDATE "users" WHERE "users"."id" = 1 + } + end + + it "chains" do + table = Table.new :users + um = Arel::UpdateManager.new + um.table table + um.where(table[:id].eq(1)).must_equal um + end + end + + describe "key" do + before do + @table = Table.new :users + @um = Arel::UpdateManager.new + @um.key = @table[:foo] + end + + it "can be set" do + @um.ast.key.must_equal @table[:foo] + end + + it "can be accessed" do + @um.key.must_equal @table[:foo] + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/depth_first_test.rb b/activerecord/test/cases/arel/visitors/depth_first_test.rb new file mode 100644 index 0000000000..3baccc7316 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/depth_first_test.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class TestDepthFirst < Arel::Test + Collector = Struct.new(:calls) do + def call(object) + calls << object + end + end + + def setup + @collector = Collector.new [] + @visitor = Visitors::DepthFirst.new @collector + end + + def test_raises_with_object + assert_raises(TypeError) do + @visitor.accept(Object.new) + end + end + + + # unary ops + [ + Arel::Nodes::Not, + Arel::Nodes::Group, + Arel::Nodes::On, + Arel::Nodes::Grouping, + Arel::Nodes::Offset, + Arel::Nodes::Ordering, + Arel::Nodes::StringJoin, + Arel::Nodes::UnqualifiedColumn, + Arel::Nodes::Top, + Arel::Nodes::Limit, + Arel::Nodes::Else, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + op = klass.new(:a) + @visitor.accept op + assert_equal [:a, op], @collector.calls + end + end + + # functions + [ + Arel::Nodes::Exists, + Arel::Nodes::Avg, + Arel::Nodes::Min, + Arel::Nodes::Max, + Arel::Nodes::Sum, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + func = klass.new(:a, "b") + @visitor.accept func + assert_equal [:a, "b", false, func], @collector.calls + end + end + + def test_named_function + func = Arel::Nodes::NamedFunction.new(:a, :b, "c") + @visitor.accept func + assert_equal [:a, :b, false, "c", func], @collector.calls + end + + def test_lock + lock = Nodes::Lock.new true + @visitor.accept lock + assert_equal [lock], @collector.calls + end + + def test_count + count = Nodes::Count.new :a, :b, "c" + @visitor.accept count + assert_equal [:a, "c", :b, count], @collector.calls + end + + def test_inner_join + join = Nodes::InnerJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + def test_full_outer_join + join = Nodes::FullOuterJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + def test_outer_join + join = Nodes::OuterJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + def test_right_outer_join + join = Nodes::RightOuterJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + [ + Arel::Nodes::Assignment, + Arel::Nodes::Between, + Arel::Nodes::Concat, + Arel::Nodes::DoesNotMatch, + Arel::Nodes::Equality, + Arel::Nodes::GreaterThan, + Arel::Nodes::GreaterThanOrEqual, + Arel::Nodes::In, + Arel::Nodes::LessThan, + Arel::Nodes::LessThanOrEqual, + Arel::Nodes::Matches, + Arel::Nodes::NotEqual, + Arel::Nodes::NotIn, + Arel::Nodes::Or, + Arel::Nodes::TableAlias, + Arel::Nodes::Values, + Arel::Nodes::As, + Arel::Nodes::DeleteStatement, + Arel::Nodes::JoinSource, + Arel::Nodes::When, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new(:a, :b) + @visitor.accept binary + assert_equal [:a, :b, binary], @collector.calls + end + end + + def test_Arel_Nodes_InfixOperation + binary = Arel::Nodes::InfixOperation.new(:o, :a, :b) + @visitor.accept binary + assert_equal [:a, :b, binary], @collector.calls + end + + # N-ary + [ + Arel::Nodes::And, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new([:a, :b, :c]) + @visitor.accept binary + assert_equal [:a, :b, :c, binary], @collector.calls + end + end + + [ + Arel::Attributes::Integer, + Arel::Attributes::Float, + Arel::Attributes::String, + Arel::Attributes::Time, + Arel::Attributes::Boolean, + Arel::Attributes::Attribute + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new(:a, :b) + @visitor.accept binary + assert_equal [:a, :b, binary], @collector.calls + end + end + + def test_table + relation = Arel::Table.new(:users) + @visitor.accept relation + assert_equal ["users", relation], @collector.calls + end + + def test_array + node = Nodes::Or.new(:a, :b) + list = [node] + @visitor.accept list + assert_equal [:a, :b, node, list], @collector.calls + end + + def test_set + node = Nodes::Or.new(:a, :b) + set = Set.new([node]) + @visitor.accept set + assert_equal [:a, :b, node, set], @collector.calls + end + + def test_hash + node = Nodes::Or.new(:a, :b) + hash = { node => node } + @visitor.accept hash + assert_equal [:a, :b, node, :a, :b, node, hash], @collector.calls + end + + def test_update_statement + stmt = Nodes::UpdateStatement.new + stmt.relation = :a + stmt.values << :b + stmt.wheres << :c + stmt.orders << :d + stmt.limit = :e + + @visitor.accept stmt + assert_equal [:a, :b, stmt.values, :c, stmt.wheres, :d, stmt.orders, + :e, stmt], @collector.calls + end + + def test_select_core + core = Nodes::SelectCore.new + core.projections << :a + core.froms = :b + core.wheres << :c + core.groups << :d + core.windows << :e + core.havings << :f + + @visitor.accept core + assert_equal [ + :a, core.projections, + :b, [], + core.source, + :c, core.wheres, + :d, core.groups, + :e, core.windows, + :f, core.havings, + core], @collector.calls + end + + def test_select_statement + ss = Nodes::SelectStatement.new + ss.cores.replace [:a] + ss.orders << :b + ss.limit = :c + ss.lock = :d + ss.offset = :e + + @visitor.accept ss + assert_equal [ + :a, ss.cores, + :b, ss.orders, + :c, + :d, + :e, + ss], @collector.calls + end + + def test_insert_statement + stmt = Nodes::InsertStatement.new + stmt.relation = :a + stmt.columns << :b + stmt.values = :c + + @visitor.accept stmt + assert_equal [:a, :b, stmt.columns, :c, stmt], @collector.calls + end + + def test_case + node = Arel::Nodes::Case.new + node.case = :a + node.conditions << :b + node.default = :c + + @visitor.accept node + assert_equal [:a, :b, node.conditions, :c, node], @collector.calls + end + + def test_node + node = Nodes::Node.new + @visitor.accept node + assert_equal [node], @collector.calls + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb b/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb new file mode 100644 index 0000000000..a07a1a050a --- /dev/null +++ b/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "concurrent" + +module Arel + module Visitors + class DummyVisitor < Visitor + def initialize + super + @barrier = Concurrent::CyclicBarrier.new(2) + end + + def visit_Arel_Visitors_DummySuperNode(node) + 42 + end + + # This is terrible, but it's the only way to reliably reproduce + # the possible race where two threads attempt to correct the + # dispatch hash at the same time. + def send(*args) + super + rescue + # Both threads try (and fail) to dispatch to the subclass's name + @barrier.wait + raise + ensure + # Then one thread successfully completes (updating the dispatch + # table in the process) before the other finishes raising its + # exception. + Thread.current[:delay].wait if Thread.current[:delay] + end + end + + class DummySuperNode + end + + class DummySubNode < DummySuperNode + end + + class DispatchContaminationTest < Arel::Spec + before do + @connection = Table.engine.connection + @table = Table.new(:users) + end + + it "dispatches properly after failing upwards" do + node = Nodes::Union.new(Nodes::True.new, Nodes::False.new) + assert_equal "( TRUE UNION FALSE )", node.to_sql + + node.first # from Nodes::Node's Enumerable mixin + + assert_equal "( TRUE UNION FALSE )", node.to_sql + end + + it "is threadsafe when implementing superclass fallback" do + visitor = DummyVisitor.new + main_thread_finished = Concurrent::Event.new + + racing_thread = Thread.new do + Thread.current[:delay] = main_thread_finished + visitor.accept DummySubNode.new + end + + assert_equal 42, visitor.accept(DummySubNode.new) + main_thread_finished.set + + assert_equal 42, racing_thread.value + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/dot_test.rb b/activerecord/test/cases/arel/visitors/dot_test.rb new file mode 100644 index 0000000000..98f3bab620 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/dot_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class TestDot < Arel::Test + def setup + @visitor = Visitors::Dot.new + end + + # functions + [ + Nodes::Sum, + Nodes::Exists, + Nodes::Max, + Nodes::Min, + Nodes::Avg, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + op = klass.new(:a, "z") + @visitor.accept op, Collectors::PlainString.new + end + end + + def test_named_function + func = Nodes::NamedFunction.new "omg", "omg" + @visitor.accept func, Collectors::PlainString.new + end + + # unary ops + [ + Arel::Nodes::Not, + Arel::Nodes::Group, + Arel::Nodes::On, + Arel::Nodes::Grouping, + Arel::Nodes::Offset, + Arel::Nodes::Ordering, + Arel::Nodes::UnqualifiedColumn, + Arel::Nodes::Top, + Arel::Nodes::Limit, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + op = klass.new(:a) + @visitor.accept op, Collectors::PlainString.new + end + end + + # binary ops + [ + Arel::Nodes::Assignment, + Arel::Nodes::Between, + Arel::Nodes::DoesNotMatch, + Arel::Nodes::Equality, + Arel::Nodes::GreaterThan, + Arel::Nodes::GreaterThanOrEqual, + Arel::Nodes::In, + Arel::Nodes::LessThan, + Arel::Nodes::LessThanOrEqual, + Arel::Nodes::Matches, + Arel::Nodes::NotEqual, + Arel::Nodes::NotIn, + Arel::Nodes::Or, + Arel::Nodes::TableAlias, + Arel::Nodes::Values, + Arel::Nodes::As, + Arel::Nodes::DeleteStatement, + Arel::Nodes::JoinSource, + Arel::Nodes::Casted, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new(:a, :b) + @visitor.accept binary, Collectors::PlainString.new + end + end + + def test_Arel_Nodes_BindParam + node = Arel::Nodes::BindParam.new(1) + collector = Collectors::PlainString.new + assert_match '[label="<f0>Arel::Nodes::BindParam"]', @visitor.accept(node, collector).value + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/ibm_db_test.rb b/activerecord/test/cases/arel/visitors/ibm_db_test.rb new file mode 100644 index 0000000000..7163cb34d3 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/ibm_db_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class IbmDbTest < Arel::Spec + before do + @visitor = IBM_DB.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "uses FETCH FIRST n ROWS to limit results" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT FETCH FIRST 1 ROWS ONLY" + end + + it "uses FETCH FIRST n ROWS in updates with a limit" do + table = Table.new(:users) + stmt = Nodes::UpdateStatement.new + stmt.relation = table + stmt.limit = Nodes::Limit.new(Nodes.build_quoted(1)) + stmt.key = table[:id] + sql = compile(stmt) + sql.must_be_like "UPDATE \"users\" WHERE \"users\".\"id\" IN (SELECT \"users\".\"id\" FROM \"users\" FETCH FIRST 1 ROWS ONLY)" + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/informix_test.rb b/activerecord/test/cases/arel/visitors/informix_test.rb new file mode 100644 index 0000000000..b0b031cca3 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/informix_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class InformixTest < Arel::Spec + before do + @visitor = Informix.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "uses FIRST n to limit results" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT FIRST 1" + end + + it "uses FIRST n in updates with a limit" do + table = Table.new(:users) + stmt = Nodes::UpdateStatement.new + stmt.relation = table + stmt.limit = Nodes::Limit.new(Nodes.build_quoted(1)) + stmt.key = table[:id] + sql = compile(stmt) + sql.must_be_like "UPDATE \"users\" WHERE \"users\".\"id\" IN (SELECT FIRST 1 \"users\".\"id\" FROM \"users\")" + end + + it "uses SKIP n to jump results" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT SKIP 10" + end + + it "uses SKIP before FIRST" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(1) + stmt.offset = Nodes::Offset.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT SKIP 1 FIRST 1" + end + + it "uses INNER JOIN to perform joins" do + core = Nodes::SelectCore.new + table = Table.new(:posts) + core.source = Nodes::JoinSource.new(table, [table.create_join(Table.new(:comments))]) + + stmt = Nodes::SelectStatement.new([core]) + sql = compile(stmt) + sql.must_be_like 'SELECT FROM "posts" INNER JOIN "comments"' + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/mssql_test.rb b/activerecord/test/cases/arel/visitors/mssql_test.rb new file mode 100644 index 0000000000..340376c3d6 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/mssql_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class MssqlTest < Arel::Spec + before do + @visitor = MSSQL.new Table.engine.connection + @table = Arel::Table.new "users" + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "should not modify query if no offset or limit" do + stmt = Nodes::SelectStatement.new + sql = compile(stmt) + sql.must_be_like "SELECT" + end + + it "should go over table PK if no .order() or .group()" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.from = @table + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY \"users\".\"id\") as _row_num FROM \"users\") as _t WHERE _row_num BETWEEN 1 AND 10" + end + + it "caches the PK lookup for order" do + connection = Minitest::Mock.new + connection.expect(:primary_key, ["id"], ["users"]) + + # We don't care how many times these methods are called + def connection.quote_table_name(*); ""; end + def connection.quote_column_name(*); ""; end + + @visitor = MSSQL.new(connection) + stmt = Nodes::SelectStatement.new + stmt.cores.first.from = @table + stmt.limit = Nodes::Limit.new(10) + + compile(stmt) + compile(stmt) + + connection.verify + end + + it "should use TOP for limited deletes" do + stmt = Nodes::DeleteStatement.new + stmt.relation = @table + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + + sql.must_be_like "DELETE TOP (10) FROM \"users\"" + end + + it "should go over query ORDER BY if .order()" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.orders << Nodes::SqlLiteral.new("order_by") + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY order_by) as _row_num) as _t WHERE _row_num BETWEEN 1 AND 10" + end + + it "should go over query GROUP BY if no .order() and there is .group()" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.groups << Nodes::SqlLiteral.new("group_by") + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY group_by) as _row_num GROUP BY group_by) as _t WHERE _row_num BETWEEN 1 AND 10" + end + + it "should use BETWEEN if both .limit() and .offset" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.offset = Nodes::Offset.new(20) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num BETWEEN 21 AND 30" + end + + it "should use >= if only .offset" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(20) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num >= 21" + end + + it "should generate subquery for .count" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.cores.first.projections << Nodes::Count.new("*") + sql = compile(stmt) + sql.must_be_like "SELECT COUNT(1) as count_id FROM (SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num BETWEEN 1 AND 10) AS subquery" + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/mysql_test.rb b/activerecord/test/cases/arel/visitors/mysql_test.rb new file mode 100644 index 0000000000..9d3bad8516 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/mysql_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class MysqlTest < Arel::Spec + before do + @visitor = MySQL.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "squashes parenthesis on multiple unions" do + subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right") + node = Nodes::Union.new subnode, Arel.sql("topright") + assert_equal 1, compile(node).scan("(").length + + subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right") + node = Nodes::Union.new Arel.sql("topleft"), subnode + assert_equal 1, compile(node).scan("(").length + end + + ### + # :'( + # http://dev.mysql.com/doc/refman/5.0/en/select.html#id3482214 + it "defaults limit to 18446744073709551615" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT FROM DUAL LIMIT 18446744073709551615 OFFSET 1" + end + + it "should escape LIMIT" do + sc = Arel::Nodes::UpdateStatement.new + sc.relation = Table.new(:users) + sc.limit = Nodes::Limit.new(Nodes.build_quoted("omg")) + assert_equal("UPDATE \"users\" LIMIT 'omg'", compile(sc)) + end + + it "uses DUAL for empty from" do + stmt = Nodes::SelectStatement.new + sql = compile(stmt) + sql.must_be_like "SELECT FROM DUAL" + end + + describe "locking" do + it "defaults to FOR UPDATE when locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + compile(node).must_be_like "FOR UPDATE" + end + + it "allows a custom string to be used as a lock" do + node = Nodes::Lock.new(Arel.sql("LOCK IN SHARE MODE")) + compile(node).must_be_like "LOCK IN SHARE MODE" + end + end + + describe "concat" do + it "concats columns" do + @table = Table.new(:users) + query = @table[:name].concat(@table[:name]) + compile(query).must_be_like %{ + CONCAT("users"."name", "users"."name") + } + end + + it "concats a string" do + @table = Table.new(:users) + query = @table[:name].concat(Nodes.build_quoted("abc")) + compile(query).must_be_like %{ + CONCAT("users"."name", 'abc') + } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/oracle12_test.rb b/activerecord/test/cases/arel/visitors/oracle12_test.rb new file mode 100644 index 0000000000..83a2ee36ca --- /dev/null +++ b/activerecord/test/cases/arel/visitors/oracle12_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class Oracle12Test < Arel::Spec + before do + @visitor = Oracle12.new Table.engine.connection + @table = Table.new(:users) + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "modified except to be minus" do + left = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 10") + right = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 20") + sql = compile Nodes::Except.new(left, right) + sql.must_be_like %{ + ( SELECT * FROM users WHERE age > 10 MINUS SELECT * FROM users WHERE age > 20 ) + } + end + + it "generates select options offset then limit" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(1) + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT OFFSET 1 ROWS FETCH FIRST 10 ROWS ONLY" + end + + describe "locking" do + it "generates ArgumentError if limit and lock are used" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.lock = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + assert_raises ArgumentError do + compile(stmt) + end + end + + it "defaults to FOR UPDATE when locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + compile(node).must_be_like "FOR UPDATE" + end + end + + describe "Nodes::BindParam" do + it "increments each bind param" do + query = @table[:name].eq(Arel::Nodes::BindParam.new(1)) + .and(@table[:id].eq(Arel::Nodes::BindParam.new(1))) + compile(query).must_be_like %{ + "users"."name" = :a1 AND "users"."id" = :a2 + } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/oracle_test.rb b/activerecord/test/cases/arel/visitors/oracle_test.rb new file mode 100644 index 0000000000..e1dfe40cf9 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/oracle_test.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class OracleTest < Arel::Spec + before do + @visitor = Oracle.new Table.engine.connection + @table = Table.new(:users) + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "modifies order when there is distinct and first value" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("foo") + sql = compile(stmt) + sql.must_be_like %{ + SELECT #{select} ORDER BY alias_0__ + } + end + + it "is idempotent with crazy query" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("foo") + + sql = compile(stmt) + sql2 = compile(stmt) + sql.must_equal sql2 + end + + it "splits orders with commas" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("foo, bar") + sql = compile(stmt) + sql.must_be_like %{ + SELECT #{select} ORDER BY alias_0__, alias_1__ + } + end + + it "splits orders with commas and function calls" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("NVL(LOWER(bar, foo), foo) DESC, UPPER(baz)") + sql = compile(stmt) + sql.must_be_like %{ + SELECT #{select} ORDER BY alias_0__ DESC, alias_1__ + } + end + + describe "Nodes::SelectStatement" do + describe "limit" do + it "adds a rownum clause" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ SELECT WHERE ROWNUM <= 10 } + end + + it "is idempotent" do + stmt = Nodes::SelectStatement.new + stmt.orders << Nodes::SqlLiteral.new("foo") + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql2 = compile stmt + sql.must_equal sql2 + end + + it "creates a subquery when there is order_by" do + stmt = Nodes::SelectStatement.new + stmt.orders << Nodes::SqlLiteral.new("foo") + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM (SELECT ORDER BY foo ) WHERE ROWNUM <= 10 + } + end + + it "creates a subquery when there is group by" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.groups << Nodes::SqlLiteral.new("foo") + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM (SELECT GROUP BY foo ) WHERE ROWNUM <= 10 + } + end + + it "creates a subquery when there is DISTINCT" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.set_quantifier = Arel::Nodes::Distinct.new + stmt.cores.first.projections << Nodes::SqlLiteral.new("id") + stmt.limit = Arel::Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM (SELECT DISTINCT id ) WHERE ROWNUM <= 10 + } + end + + it "creates a different subquery when there is an offset" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.offset = Nodes::Offset.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (SELECT ) raw_sql_ + WHERE rownum <= 20 + ) + WHERE raw_rnum_ > 10 + } + end + + it "creates a subquery when there is limit and offset with BindParams" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(Nodes::BindParam.new(1)) + stmt.offset = Nodes::Offset.new(Nodes::BindParam.new(1)) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (SELECT ) raw_sql_ + WHERE rownum <= (:a1 + :a2) + ) + WHERE raw_rnum_ > :a3 + } + end + + it "is idempotent with different subquery" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.offset = Nodes::Offset.new(10) + sql = compile stmt + sql2 = compile stmt + sql.must_equal sql2 + end + end + + describe "only offset" do + it "creates a select from subquery with rownum condition" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (SELECT) raw_sql_ + ) + WHERE raw_rnum_ > 10 + } + end + end + end + + it "modified except to be minus" do + left = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 10") + right = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 20") + sql = compile Nodes::Except.new(left, right) + sql.must_be_like %{ + ( SELECT * FROM users WHERE age > 10 MINUS SELECT * FROM users WHERE age > 20 ) + } + end + + describe "locking" do + it "defaults to FOR UPDATE when locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + compile(node).must_be_like "FOR UPDATE" + end + end + + describe "Nodes::BindParam" do + it "increments each bind param" do + query = @table[:name].eq(Arel::Nodes::BindParam.new(1)) + .and(@table[:id].eq(Arel::Nodes::BindParam.new(1))) + compile(query).must_be_like %{ + "users"."name" = :a1 AND "users"."id" = :a2 + } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/postgres_test.rb b/activerecord/test/cases/arel/visitors/postgres_test.rb new file mode 100644 index 0000000000..f7f2c76b6f --- /dev/null +++ b/activerecord/test/cases/arel/visitors/postgres_test.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class PostgresTest < Arel::Spec + before do + @visitor = PostgreSQL.new Table.engine.connection + @table = Table.new(:users) + @attr = @table[:id] + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + describe "locking" do + it "defaults to FOR UPDATE" do + compile(Nodes::Lock.new(Arel.sql("FOR UPDATE"))).must_be_like %{ + FOR UPDATE + } + end + + it "allows a custom string to be used as a lock" do + node = Nodes::Lock.new(Arel.sql("FOR SHARE")) + compile(node).must_be_like %{ + FOR SHARE + } + end + end + + it "should escape LIMIT" do + sc = Arel::Nodes::SelectStatement.new + sc.limit = Nodes::Limit.new(Nodes.build_quoted("omg")) + sc.cores.first.projections << Arel.sql("DISTINCT ON") + sc.orders << Arel.sql("xyz") + sql = compile(sc) + assert_match(/LIMIT 'omg'/, sql) + assert_equal 1, sql.scan(/LIMIT/).length, "should have one limit" + end + + it "should support DISTINCT ON" do + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::DistinctOn.new(Arel.sql("aaron")) + assert_match "DISTINCT ON ( aaron )", compile(core) + end + + it "should support DISTINCT" do + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::Distinct.new + assert_equal "SELECT DISTINCT", compile(core) + end + + it "encloses LATERAL queries in parens" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + compile(subquery.lateral).must_be_like %{ + LATERAL (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') + } + end + + it "produces LATERAL queries with alias" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + compile(subquery.lateral("bar")).must_be_like %{ + LATERAL (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') bar + } + end + + describe "Nodes::Matches" do + it "should know how to visit" do + node = @table[:name].matches("foo%") + node.must_be_kind_of Nodes::Matches + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" ILIKE 'foo%' + } + end + + it "should know how to visit case sensitive" do + node = @table[:name].matches("foo%", nil, true) + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].matches("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" ILIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') + } + end + end + + describe "Nodes::DoesNotMatch" do + it "should know how to visit" do + node = @table[:name].does_not_match("foo%") + node.must_be_kind_of Nodes::DoesNotMatch + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" NOT ILIKE 'foo%' + } + end + + it "should know how to visit case sensitive" do + node = @table[:name].does_not_match("foo%", nil, true) + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" NOT LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].does_not_match("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" NOT ILIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].does_not_match("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" NOT ILIKE 'foo%') + } + end + end + + describe "Nodes::Regexp" do + it "should know how to visit" do + node = @table[:name].matches_regexp("foo.*") + node.must_be_kind_of Nodes::Regexp + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" ~ 'foo.*' + } + end + + it "can handle case insensitive" do + node = @table[:name].matches_regexp("foo.*", false) + node.must_be_kind_of Nodes::Regexp + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" ~* 'foo.*' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].matches_regexp("foo.*")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" ~ 'foo.*') + } + end + end + + describe "Nodes::NotRegexp" do + it "should know how to visit" do + node = @table[:name].does_not_match_regexp("foo.*") + node.must_be_kind_of Nodes::NotRegexp + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" !~ 'foo.*' + } + end + + it "can handle case insensitive" do + node = @table[:name].does_not_match_regexp("foo.*", false) + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" !~* 'foo.*' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].does_not_match_regexp("foo.*")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" !~ 'foo.*') + } + end + end + + describe "Nodes::BindParam" do + it "increments each bind param" do + query = @table[:name].eq(Arel::Nodes::BindParam.new(1)) + .and(@table[:id].eq(Arel::Nodes::BindParam.new(1))) + compile(query).must_be_like %{ + "users"."name" = $1 AND "users"."id" = $2 + } + end + end + + describe "Nodes::Cube" do + it "should know how to visit with array arguments" do + node = Arel::Nodes::Cube.new([@table[:name], @table[:bool]]) + compile(node).must_be_like %{ + CUBE( "users"."name", "users"."bool" ) + } + end + + it "should know how to visit with CubeDimension Argument" do + dimensions = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]]) + node = Arel::Nodes::Cube.new(dimensions) + compile(node).must_be_like %{ + CUBE( "users"."name", "users"."bool" ) + } + end + + it "should know how to generate parenthesis when supplied with many Dimensions" do + dim1 = Arel::Nodes::GroupingElement.new(@table[:name]) + dim2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]]) + node = Arel::Nodes::Cube.new([dim1, dim2]) + compile(node).must_be_like %{ + CUBE( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) ) + } + end + end + + describe "Nodes::GroupingSet" do + it "should know how to visit with array arguments" do + node = Arel::Nodes::GroupingSet.new([@table[:name], @table[:bool]]) + compile(node).must_be_like %{ + GROUPING SETS( "users"."name", "users"."bool" ) + } + end + + it "should know how to visit with CubeDimension Argument" do + group = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]]) + node = Arel::Nodes::GroupingSet.new(group) + compile(node).must_be_like %{ + GROUPING SETS( "users"."name", "users"."bool" ) + } + end + + it "should know how to generate parenthesis when supplied with many Dimensions" do + group1 = Arel::Nodes::GroupingElement.new(@table[:name]) + group2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]]) + node = Arel::Nodes::GroupingSet.new([group1, group2]) + compile(node).must_be_like %{ + GROUPING SETS( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) ) + } + end + end + + describe "Nodes::RollUp" do + it "should know how to visit with array arguments" do + node = Arel::Nodes::RollUp.new([@table[:name], @table[:bool]]) + compile(node).must_be_like %{ + ROLLUP( "users"."name", "users"."bool" ) + } + end + + it "should know how to visit with CubeDimension Argument" do + group = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]]) + node = Arel::Nodes::RollUp.new(group) + compile(node).must_be_like %{ + ROLLUP( "users"."name", "users"."bool" ) + } + end + + it "should know how to generate parenthesis when supplied with many Dimensions" do + group1 = Arel::Nodes::GroupingElement.new(@table[:name]) + group2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]]) + node = Arel::Nodes::RollUp.new([group1, group2]) + compile(node).must_be_like %{ + ROLLUP( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) ) + } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/sqlite_test.rb b/activerecord/test/cases/arel/visitors/sqlite_test.rb new file mode 100644 index 0000000000..6650b6ff3a --- /dev/null +++ b/activerecord/test/cases/arel/visitors/sqlite_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class SqliteTest < Arel::Spec + before do + @visitor = SQLite.new Table.engine.connection_pool + end + + it "defaults limit to -1" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(1) + sql = @visitor.accept(stmt, Collectors::SQLString.new).value + sql.must_be_like "SELECT LIMIT -1 OFFSET 1" + end + + it "does not support locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + assert_equal "", @visitor.accept(node, Collectors::SQLString.new).value + end + + it "does not support boolean" do + node = Nodes::True.new() + assert_equal "1", @visitor.accept(node, Collectors::SQLString.new).value + node = Nodes::False.new() + assert_equal "0", @visitor.accept(node, Collectors::SQLString.new).value + 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 new file mode 100644 index 0000000000..e8ac50bfa3 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/to_sql_test.rb @@ -0,0 +1,654 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "bigdecimal" + +module Arel + module Visitors + describe "the to_sql visitor" do + before do + @conn = FakeRecord::Base.new + @visitor = ToSql.new @conn.connection + @table = Table.new(:users) + @attr = @table[:id] + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "works with BindParams" do + node = Nodes::BindParam.new(1) + sql = compile node + sql.must_be_like "?" + end + + it "does not quote BindParams used as part of a Values" do + bp = Nodes::BindParam.new(1) + values = Nodes::Values.new([bp]) + sql = compile values + sql.must_be_like "VALUES (?)" + end + + it "can define a dispatch method" do + visited = false + viz = Class.new(Arel::Visitors::Visitor) { + define_method(:hello) do |node, c| + visited = true + end + + def dispatch + { Arel::Table => "hello" } + end + }.new + + viz.accept(@table, Collectors::SQLString.new) + assert visited, "hello method was called" + end + + it "should not quote sql literals" do + node = @table[Arel.star] + sql = compile node + sql.must_be_like '"users".*' + end + + it "should visit named functions" do + function = Nodes::NamedFunction.new("omg", [Arel.star]) + assert_equal "omg(*)", compile(function) + end + + it "should chain predications on named functions" do + function = Nodes::NamedFunction.new("omg", [Arel.star]) + sql = compile(function.eq(2)) + sql.must_be_like %{ omg(*) = 2 } + end + + it "should handle nil with named functions" do + function = Nodes::NamedFunction.new("omg", [Arel.star]) + sql = compile(function.eq(nil)) + sql.must_be_like %{ omg(*) IS NULL } + end + + it "should visit built-in functions" do + function = Nodes::Count.new([Arel.star]) + assert_equal "COUNT(*)", compile(function) + + function = Nodes::Sum.new([Arel.star]) + assert_equal "SUM(*)", compile(function) + + function = Nodes::Max.new([Arel.star]) + assert_equal "MAX(*)", compile(function) + + function = Nodes::Min.new([Arel.star]) + assert_equal "MIN(*)", compile(function) + + function = Nodes::Avg.new([Arel.star]) + assert_equal "AVG(*)", compile(function) + end + + it "should visit built-in functions operating on distinct values" do + function = Nodes::Count.new([Arel.star]) + function.distinct = true + assert_equal "COUNT(DISTINCT *)", compile(function) + + function = Nodes::Sum.new([Arel.star]) + function.distinct = true + assert_equal "SUM(DISTINCT *)", compile(function) + + function = Nodes::Max.new([Arel.star]) + function.distinct = true + assert_equal "MAX(DISTINCT *)", compile(function) + + function = Nodes::Min.new([Arel.star]) + function.distinct = true + assert_equal "MIN(DISTINCT *)", compile(function) + + function = Nodes::Avg.new([Arel.star]) + function.distinct = true + assert_equal "AVG(DISTINCT *)", compile(function) + end + + it "works with lists" do + function = Nodes::NamedFunction.new("omg", [Arel.star, Arel.star]) + assert_equal "omg(*, *)", compile(function) + end + + describe "Nodes::Equality" do + it "should escape strings" do + test = Table.new(:users)[:name].eq "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" = 'Aaron Patterson' + } + end + + it "should handle false" do + table = Table.new(:users) + val = Nodes.build_quoted(false, table[:active]) + sql = compile Nodes::Equality.new(val, val) + sql.must_be_like %{ 'f' = 'f' } + end + + it "should handle nil" do + sql = compile Nodes::Equality.new(@table[:name], nil) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::Grouping" do + it "wraps nested groupings in brackets only once" do + sql = compile Nodes::Grouping.new(Nodes::Grouping.new(Nodes.build_quoted("foo"))) + sql.must_equal "('foo')" + end + end + + describe "Nodes::NotEqual" do + it "should handle false" do + val = Nodes.build_quoted(false, @table[:active]) + sql = compile Nodes::NotEqual.new(@table[:active], val) + sql.must_be_like %{ "users"."active" != 'f' } + end + + it "should handle nil" do + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::NotEqual.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + + it "should visit string subclass" do + [ + Class.new(String).new(":'("), + Class.new(Class.new(String)).new(":'("), + ].each do |obj| + val = Nodes.build_quoted(obj, @table[:active]) + sql = compile Nodes::NotEqual.new(@table[:name], val) + sql.must_be_like %{ "users"."name" != ':\\'(' } + end + end + + it "should visit_Class" do + compile(Nodes.build_quoted(DateTime)).must_equal "'DateTime'" + end + + it "should escape LIMIT" do + sc = Arel::Nodes::SelectStatement.new + sc.limit = Arel::Nodes::Limit.new(Nodes.build_quoted("omg")) + assert_match(/LIMIT 'omg'/, compile(sc)) + end + + it "should contain a single space before ORDER BY" do + table = Table.new(:users) + test = table.order(table[:name]) + sql = compile test + assert_match(/"users" ORDER BY/, sql) + end + + it "should quote LIMIT without column type coercion" do + table = Table.new(:users) + sc = table.where(table[:name].eq(0)).take(1).ast + assert_match(/WHERE "users"."name" = 0 LIMIT 1/, compile(sc)) + end + + it "should visit_DateTime" do + dt = DateTime.now + table = Table.new(:users) + test = table[:created_at].eq dt + sql = compile test + + sql.must_be_like %{"users"."created_at" = '#{dt.strftime("%Y-%m-%d %H:%M:%S")}'} + end + + it "should visit_Float" do + test = Table.new(:products)[:price].eq 2.14 + sql = compile test + sql.must_be_like %{"products"."price" = 2.14} + end + + it "should visit_Not" do + sql = compile Nodes::Not.new(Arel.sql("foo")) + sql.must_be_like "NOT (foo)" + end + + it "should apply Not to the whole expression" do + node = Nodes::And.new [@attr.eq(10), @attr.eq(11)] + sql = compile Nodes::Not.new(node) + sql.must_be_like %{NOT ("users"."id" = 10 AND "users"."id" = 11)} + end + + it "should visit_As" do + as = Nodes::As.new(Arel.sql("foo"), Arel.sql("bar")) + sql = compile as + sql.must_be_like "foo AS bar" + end + + it "should visit_Bignum" do + compile 8787878092 + end + + it "should visit_Hash" do + compile(Nodes.build_quoted(a: 1)) + end + + it "should visit_Set" do + compile Nodes.build_quoted(Set.new([1, 2])) + end + + it "should visit_BigDecimal" do + compile Nodes.build_quoted(BigDecimal("2.14")) + end + + it "should visit_Date" do + dt = Date.today + table = Table.new(:users) + test = table[:created_at].eq dt + sql = compile test + + sql.must_be_like %{"users"."created_at" = '#{dt.strftime("%Y-%m-%d")}'} + end + + it "should visit_NilClass" do + compile(Nodes.build_quoted(nil)).must_be_like "NULL" + end + + it "unsupported input should raise UnsupportedVisitError" do + error = assert_raises(UnsupportedVisitError) { compile(nil) } + assert_match(/\AUnsupported/, error.message) + end + + it "should visit_Arel_SelectManager, which is a subquery" do + mgr = Table.new(:foo).project(:bar) + compile(mgr).must_be_like '(SELECT bar FROM "foo")' + end + + it "should visit_Arel_Nodes_And" do + node = Nodes::And.new [@attr.eq(10), @attr.eq(11)] + compile(node).must_be_like %{ + "users"."id" = 10 AND "users"."id" = 11 + } + end + + it "should visit_Arel_Nodes_Or" do + node = Nodes::Or.new @attr.eq(10), @attr.eq(11) + compile(node).must_be_like %{ + "users"."id" = 10 OR "users"."id" = 11 + } + end + + it "should visit_Arel_Nodes_Assignment" do + column = @table["id"] + node = Nodes::Assignment.new( + Nodes::UnqualifiedColumn.new(column), + Nodes::UnqualifiedColumn.new(column) + ) + compile(node).must_be_like %{ + "id" = "id" + } + end + + it "should visit visit_Arel_Attributes_Time" do + attr = Attributes::Time.new(@attr.relation, @attr.name) + compile attr + end + + it "should visit_TrueClass" do + test = Table.new(:users)[:bool].eq(true) + compile(test).must_be_like %{ "users"."bool" = 't' } + end + + describe "Nodes::Matches" do + it "should know how to visit" do + node = @table[:name].matches("foo%") + compile(node).must_be_like %{ + "users"."name" LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].matches("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" LIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" LIKE 'foo%') + } + end + end + + describe "Nodes::DoesNotMatch" do + it "should know how to visit" do + node = @table[:name].does_not_match("foo%") + compile(node).must_be_like %{ + "users"."name" NOT LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].does_not_match("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" NOT LIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].does_not_match("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" NOT LIKE 'foo%') + } + end + end + + describe "Nodes::Ordering" do + it "should know how to visit" do + node = @attr.desc + compile(node).must_be_like %{ + "users"."id" DESC + } + end + end + + describe "Nodes::In" do + it "should know how to visit" do + node = @attr.in [1, 2, 3] + compile(node).must_be_like %{ + "users"."id" IN (1, 2, 3) + } + end + + it "should return 1=0 when empty right which is always false" do + node = @attr.in [] + compile(node).must_equal "1=0" + end + + it "can handle two dot ranges" do + node = @attr.between 1..3 + compile(node).must_be_like %{ + "users"."id" BETWEEN 1 AND 3 + } + end + + it "can handle three dot ranges" do + node = @attr.between 1...3 + compile(node).must_be_like %{ + "users"."id" >= 1 AND "users"."id" < 3 + } + end + + it "can handle ranges bounded by infinity" do + node = @attr.between 1..Float::INFINITY + compile(node).must_be_like %{ + "users"."id" >= 1 + } + node = @attr.between(-Float::INFINITY..3) + compile(node).must_be_like %{ + "users"."id" <= 3 + } + node = @attr.between(-Float::INFINITY...3) + compile(node).must_be_like %{ + "users"."id" < 3 + } + node = @attr.between(-Float::INFINITY..Float::INFINITY) + compile(node).must_be_like %{1=1} + end + + it "can handle subqueries" do + table = Table.new(:users) + subquery = table.project(:id).where(table[:name].eq("Aaron")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" = 'Aaron') + } + end + end + + describe "Nodes::InfixOperation" do + it "should handle Multiplication" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) * Arel::Attributes::Decimal.new(Table.new(:currency_rates), :rate) + compile(node).must_equal %("products"."price" * "currency_rates"."rate") + end + + it "should handle Division" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) / 5 + compile(node).must_equal %("products"."price" / 5) + end + + it "should handle Addition" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) + 6 + compile(node).must_equal %(("products"."price" + 6)) + end + + it "should handle Subtraction" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) - 7 + compile(node).must_equal %(("products"."price" - 7)) + end + + it "should handle Concatenation" do + table = Table.new(:users) + node = table[:name].concat(table[:name]) + compile(node).must_equal %("users"."name" || "users"."name") + end + + it "should handle BitwiseAnd" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) & 16 + compile(node).must_equal %(("products"."bitmap" & 16)) + end + + it "should handle BitwiseOr" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) | 16 + compile(node).must_equal %(("products"."bitmap" | 16)) + end + + it "should handle BitwiseXor" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) ^ 16 + compile(node).must_equal %(("products"."bitmap" ^ 16)) + end + + it "should handle BitwiseShiftLeft" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) << 4 + compile(node).must_equal %(("products"."bitmap" << 4)) + end + + it "should handle BitwiseShiftRight" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) >> 4 + compile(node).must_equal %(("products"."bitmap" >> 4)) + end + + it "should handle arbitrary operators" do + node = Arel::Nodes::InfixOperation.new( + "&&", + Arel::Attributes::String.new(Table.new(:products), :name), + Arel::Attributes::String.new(Table.new(:products), :name) + ) + compile(node).must_equal %("products"."name" && "products"."name") + end + end + + describe "Nodes::UnaryOperation" do + it "should handle BitwiseNot" do + node = ~ Arel::Attributes::Integer.new(Table.new(:products), :bitmap) + compile(node).must_equal %( ~ "products"."bitmap") + end + + it "should handle arbitrary operators" do + node = Arel::Nodes::UnaryOperation.new("!", Arel::Attributes::String.new(Table.new(:products), :active)) + compile(node).must_equal %( ! "products"."active") + end + end + + describe "Nodes::NotIn" do + it "should know how to visit" do + node = @attr.not_in [1, 2, 3] + compile(node).must_be_like %{ + "users"."id" NOT IN (1, 2, 3) + } + end + + it "should return 1=1 when empty right which is always true" do + node = @attr.not_in [] + compile(node).must_equal "1=1" + end + + it "can handle two dot ranges" do + node = @attr.not_between 1..3 + compile(node).must_equal( + %{("users"."id" < 1 OR "users"."id" > 3)} + ) + end + + it "can handle three dot ranges" do + node = @attr.not_between 1...3 + compile(node).must_equal( + %{("users"."id" < 1 OR "users"."id" >= 3)} + ) + end + + it "can handle ranges bounded by infinity" do + node = @attr.not_between 1..Float::INFINITY + compile(node).must_be_like %{ + "users"."id" < 1 + } + node = @attr.not_between(-Float::INFINITY..3) + compile(node).must_be_like %{ + "users"."id" > 3 + } + node = @attr.not_between(-Float::INFINITY...3) + compile(node).must_be_like %{ + "users"."id" >= 3 + } + node = @attr.not_between(-Float::INFINITY..Float::INFINITY) + compile(node).must_be_like %{1=0} + end + + it "can handle subqueries" do + table = Table.new(:users) + subquery = table.project(:id).where(table[:name].eq("Aaron")) + node = @attr.not_in subquery + compile(node).must_be_like %{ + "users"."id" NOT IN (SELECT id FROM "users" WHERE "users"."name" = 'Aaron') + } + end + end + + describe "Constants" do + it "should handle true" do + test = Table.new(:users).create_true + compile(test).must_be_like %{ + TRUE + } + end + + it "should handle false" do + test = Table.new(:users).create_false + compile(test).must_be_like %{ + FALSE + } + end + end + + describe "TableAlias" do + it "should use the underlying table for checking columns" do + test = Table.new(:users).alias("zomgusers")[:id].eq "3" + compile(test).must_be_like %{ + "zomgusers"."id" = '3' + } + end + end + + describe "distinct on" do + it "raises not implemented error" do + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::DistinctOn.new(Arel.sql("aaron")) + + assert_raises(NotImplementedError) do + compile(core) + end + end + end + + describe "Nodes::Regexp" do + it "raises not implemented error" do + node = Arel::Nodes::Regexp.new(@table[:name], Nodes.build_quoted("foo%")) + + assert_raises(NotImplementedError) do + compile(node) + end + end + end + + describe "Nodes::NotRegexp" do + it "raises not implemented error" do + node = Arel::Nodes::NotRegexp.new(@table[:name], Nodes.build_quoted("foo%")) + + assert_raises(NotImplementedError) do + compile(node) + end + end + end + + describe "Nodes::Case" do + it "supports simple case expressions" do + node = Arel::Nodes::Case.new(@table[:name]) + .when("foo").then(1) + .else(0) + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 ELSE 0 END + } + end + + it "supports extended case expressions" do + node = Arel::Nodes::Case.new + .when(@table[:name].in(%w(foo bar))).then(1) + .else(0) + + compile(node).must_be_like %{ + CASE WHEN "users"."name" IN ('foo', 'bar') THEN 1 ELSE 0 END + } + end + + it "works without default branch" do + node = Arel::Nodes::Case.new(@table[:name]) + .when("foo").then(1) + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 END + } + end + + it "allows chaining multiple conditions" do + node = Arel::Nodes::Case.new(@table[:name]) + .when("foo").then(1) + .when("bar").then(2) + .else(0) + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 WHEN 'bar' THEN 2 ELSE 0 END + } + end + + it "supports #when with two arguments and no #then" do + node = Arel::Nodes::Case.new @table[:name] + + { foo: 1, bar: 0 }.reduce(node) { |_node, pair| _node.when(*pair) } + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 WHEN 'bar' THEN 0 END + } + end + + it "can be chained as a predicate" do + node = @table[:name].when("foo").then("bar").else("baz") + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 'bar' ELSE 'baz' END + } + end + end + end + end +end diff --git a/activerecord/test/cases/associations/association_scope_test.rb b/activerecord/test/cases/associations/association_scope_test.rb deleted file mode 100644 index c322333f6d..0000000000 --- a/activerecord/test/cases/associations/association_scope_test.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "cases/helper" -require "models/post" -require "models/author" - -module ActiveRecord - module Associations - class AssociationScopeTest < ActiveRecord::TestCase - test "does not duplicate conditions" do - scope = AssociationScope.scope(Author.new.association(:welcome_posts), - Author.connection) - binds = scope.where_clause.binds.map(&:value) - assert_equal binds.uniq, binds - end - end - end -end diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index 5875a1871f..0cc4ed7127 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/developer" require "models/project" @@ -35,6 +37,21 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal companies(:first_firm).name, firm.name end + def test_assigning_belongs_to_on_destroyed_object + client = Client.create!(name: "Client") + client.destroy! + assert_raise(frozen_error_class) { client.firm = nil } + assert_raise(frozen_error_class) { client.firm = Firm.new(name: "Firm") } + end + + def test_eager_loading_wont_mutate_owner_record + client = Client.eager_load(:firm_with_basic_id).first + assert_not_predicate client, :firm_id_came_from_user? + + client = Client.preload(:firm_with_basic_id).first + assert_not_predicate client, :firm_id_came_from_user? + end + def test_missing_attribute_error_is_raised_when_no_foreign_key_attribute assert_raises(ActiveModel::MissingAttributeError) { Client.select(:id).first.firm } end @@ -77,7 +94,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end account = model.new - assert account.valid? + assert_predicate account, :valid? ensure ActiveRecord::Base.belongs_to_required_by_default = original_value end @@ -93,7 +110,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end account = model.new - assert_not account.valid? + assert_not_predicate account, :valid? assert_equal [{ error: :blank }], account.errors.details[:company] ensure ActiveRecord::Base.belongs_to_required_by_default = original_value @@ -110,12 +127,50 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end account = model.new - assert_not account.valid? + assert_not_predicate account, :valid? assert_equal [{ error: :blank }], account.errors.details[:company] ensure ActiveRecord::Base.belongs_to_required_by_default = original_value end + def test_default + david = developers(:david) + jamis = developers(:jamis) + + model = Class.new(ActiveRecord::Base) do + self.table_name = "ships" + def self.name; "Temp"; end + belongs_to :developer, default: -> { david } + end + + ship = model.create! + assert_equal david, ship.developer + + ship = model.create!(developer: jamis) + assert_equal jamis, ship.developer + + ship.update!(developer: nil) + assert_equal david, ship.developer + end + + def test_default_with_lambda + model = Class.new(ActiveRecord::Base) do + self.table_name = "ships" + def self.name; "Temp"; end + belongs_to :developer, default: -> { default_developer } + + def default_developer + Developer.first + end + end + + ship = model.create! + assert_equal developers(:david), ship.developer + + ship = model.create!(developer: developers(:jamis)) + assert_equal developers(:jamis), ship.developer + end + def test_default_scope_on_relations_is_not_cached counter = 0 @@ -177,6 +232,8 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase remove_column :admin_users, :region_id if column_exists?(:admin_users, :region_id) drop_table :admin_regions, if_exists: true end + + Admin::User.reset_column_information end def test_natural_assignment @@ -204,14 +261,14 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase Firm.create("name" => "Apple") Client.create("name" => "Citibank", :firm_name => "Apple") citibank_result = Client.all.merge!(where: { name: "Citibank" }, includes: :firm_with_primary_key).first - assert citibank_result.association(:firm_with_primary_key).loaded? + assert_predicate citibank_result.association(:firm_with_primary_key), :loaded? end def test_eager_loading_with_primary_key_as_symbol Firm.create("name" => "Apple") Client.create("name" => "Citibank", :firm_name => "Apple") citibank_result = Client.all.merge!(where: { name: "Citibank" }, includes: :firm_with_primary_key_symbols).first - assert citibank_result.association(:firm_with_primary_key_symbols).loaded? + assert_predicate citibank_result.association(:firm_with_primary_key_symbols), :loaded? end def test_creating_the_belonging_object @@ -223,6 +280,15 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal apple, citibank.firm end + def test_creating_the_belonging_object_from_new_record + citibank = Account.new("credit_limit" => 10) + apple = citibank.create_firm("name" => "Apple") + assert_equal apple, citibank.firm + citibank.save + citibank.reload + assert_equal apple, citibank.firm + end + def test_creating_the_belonging_object_with_primary_key client = Client.create(name: "Primary key client") apple = client.create_firm_with_primary_key("name" => "Apple") @@ -278,7 +344,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase client = Client.create!(name: "Jimmy") account = client.create_account!(credit_limit: 10) assert_equal account, client.account - assert account.persisted? + assert_predicate account, :persisted? client.save client.reload assert_equal account, client.account @@ -288,7 +354,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase client = Client.create!(name: "Jimmy") assert_raise(ActiveRecord::RecordInvalid) { client.create_account! } assert_not_nil client.account - assert client.account.new_record? + assert_predicate client.account, :new_record? end def test_reloading_the_belonging_object @@ -342,7 +408,6 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase sponsor.sponsorable = Member.new name: "Bert" assert_equal Member, sponsor.association(:sponsorable).send(:klass) - assert_equal "members", sponsor.association(:sponsorable).aliased_table_name end def test_with_polymorphic_and_condition @@ -401,7 +466,20 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase def test_belongs_to_with_primary_key_counter debate = Topic.create("title" => "debate") debate2 = Topic.create("title" => "debate2") - reply = Reply.create("title" => "blah!", "content" => "world around!", "parent_title" => "debate") + reply = Reply.create("title" => "blah!", "content" => "world around!", "parent_title" => "debate2") + + assert_equal 0, debate.reload.replies_count + assert_equal 1, debate2.reload.replies_count + + reply.parent_title = "debate" + reply.save! + + assert_equal 1, debate.reload.replies_count + assert_equal 0, debate2.reload.replies_count + + assert_no_queries do + reply.topic_with_primary_key = debate + end assert_equal 1, debate.reload.replies_count assert_equal 0, debate2.reload.replies_count @@ -479,6 +557,48 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal 1, Topic.find(topic.id)[:replies_count] end + def test_belongs_to_counter_after_touch + topic = Topic.create!(title: "topic") + + assert_equal 0, topic.replies_count + assert_equal 0, topic.after_touch_called + + reply = Reply.create!(title: "blah!", content: "world around!", topic_with_primary_key: topic) + + assert_equal 1, topic.replies_count + assert_equal 1, topic.after_touch_called + + reply.destroy! + + assert_equal 0, topic.replies_count + assert_equal 2, topic.after_touch_called + end + + def test_belongs_to_touch_with_reassigning + debate = Topic.create!(title: "debate") + debate2 = Topic.create!(title: "debate2") + reply = Reply.create!(title: "blah!", content: "world around!", parent_title: "debate2") + + time = 1.day.ago + + debate.touch(time: time) + debate2.touch(time: time) + + reply.parent_title = "debate" + reply.save! + + assert_operator debate.reload.updated_at, :>, time + assert_operator debate2.reload.updated_at, :>, time + + debate.touch(time: time) + debate2.touch(time: time) + + reply.topic_with_primary_key = debate2 + + assert_operator debate.reload.updated_at, :>, time + assert_operator debate2.reload.updated_at, :>, time + end + def test_belongs_to_with_touch_option_on_touch line_item = LineItem.create! Invoice.create!(line_items: [line_item]) @@ -586,10 +706,10 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase final_cut = Client.new("name" => "Final Cut") firm = Firm.find(1) final_cut.firm = firm - assert !final_cut.persisted? + assert_not_predicate final_cut, :persisted? assert final_cut.save - assert final_cut.persisted? - assert firm.persisted? + assert_predicate final_cut, :persisted? + assert_predicate firm, :persisted? assert_equal firm, final_cut.firm final_cut.association(:firm).reload assert_equal firm, final_cut.firm @@ -599,10 +719,10 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase final_cut = Client.new("name" => "Final Cut") firm = Firm.find(1) final_cut.firm_with_primary_key = firm - assert !final_cut.persisted? + assert_not_predicate final_cut, :persisted? assert final_cut.save - assert final_cut.persisted? - assert firm.persisted? + assert_predicate final_cut, :persisted? + assert_predicate firm, :persisted? assert_equal firm, final_cut.firm_with_primary_key final_cut.association(:firm_with_primary_key).reload assert_equal firm, final_cut.firm_with_primary_key @@ -610,8 +730,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase def test_new_record_with_foreign_key_but_no_object client = Client.new("firm_id" => 1) - # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first - assert_equal Firm.all.merge!(order: "id").first, client.firm_with_basic_id + assert_equal Firm.first, client.firm_with_basic_id end def test_setting_foreign_key_after_nil_target_loaded @@ -750,7 +869,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase def test_cant_save_readonly_association assert_raise(ActiveRecord::ReadOnlyRecord) { companies(:first_client).readonly_firm.save! } - assert companies(:first_client).readonly_firm.readonly? + assert_predicate companies(:first_client).readonly_firm, :readonly? end def test_polymorphic_assignment_foreign_key_type_string @@ -891,6 +1010,30 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal error.message, "The :dependent option must be one of [:destroy, :delete], but is :nullify" end + class DestroyableBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "UndestroyableAuthor", dependent: :destroy + end + + class UndestroyableAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "DestroyableBook", foreign_key: "author_id" + before_destroy :dont + + def dont + throw(:abort) + end + end + + def test_dependency_should_halt_parent_destruction + author = UndestroyableAuthor.create!(name: "Test") + book = DestroyableBook.create!(author: author) + + assert_no_difference ["UndestroyableAuthor.count", "DestroyableBook.count"] do + assert_not book.destroy + end + end + def test_attributes_are_being_set_when_initialized_from_belongs_to_association_with_where_clause new_firm = accounts(:signals37).build_firm(name: "Apple") assert_equal new_firm.name, "Apple" @@ -909,15 +1052,15 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase firm_proxy = client.send(:association_instance_get, :firm) firm_with_condition_proxy = client.send(:association_instance_get, :firm_with_condition) - assert !firm_proxy.stale_target? - assert !firm_with_condition_proxy.stale_target? + assert_not_predicate firm_proxy, :stale_target? + assert_not_predicate firm_with_condition_proxy, :stale_target? assert_equal companies(:first_firm), client.firm assert_equal companies(:first_firm), client.firm_with_condition client.client_of = companies(:another_firm).id - assert firm_proxy.stale_target? - assert firm_with_condition_proxy.stale_target? + assert_predicate firm_proxy, :stale_target? + assert_predicate firm_with_condition_proxy, :stale_target? assert_equal companies(:another_firm), client.firm assert_equal companies(:another_firm), client.firm_with_condition end @@ -928,12 +1071,12 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase sponsor.sponsorable proxy = sponsor.send(:association_instance_get, :sponsorable) - assert !proxy.stale_target? + assert_not_predicate proxy, :stale_target? assert_equal members(:groucho), sponsor.sponsorable sponsor.sponsorable_id = members(:some_other_guy).id - assert proxy.stale_target? + assert_predicate proxy, :stale_target? assert_equal members(:some_other_guy), sponsor.sponsorable end @@ -943,12 +1086,12 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase sponsor.sponsorable proxy = sponsor.send(:association_instance_get, :sponsorable) - assert !proxy.stale_target? + assert_not_predicate proxy, :stale_target? assert_equal members(:groucho), sponsor.sponsorable sponsor.sponsorable_type = "Firm" - assert proxy.stale_target? + assert_predicate proxy, :stale_target? assert_equal companies(:first_firm), sponsor.sponsorable end @@ -970,9 +1113,20 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase post = posts(:welcome) comment = comments(:greetings) - assert_difference lambda { post.reload.tags_count }, -1 do + assert_equal post.id, comment.id + + assert_difference "post.reload.tags_count", -1 do assert_difference "comment.reload.tags_count", +1 do tagging.taggable = comment + tagging.save! + end + end + + assert_difference "comment.reload.tags_count", -1 do + assert_difference "post.reload.tags_count", +1 do + tagging.taggable_type = post.class.polymorphic_name + tagging.taggable_id = post.id + tagging.save! end end end @@ -1082,7 +1236,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase comment.post_id = 9223372036854775808 # out of range in the bigint assert_nil comment.post - assert_not comment.valid? + assert_not_predicate comment, :valid? assert_equal [{ error: :blank }], comment.errors.details[:post] end @@ -1102,7 +1256,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase citibank.firm_id = apple.id.to_s - assert !citibank.association(:firm).stale_target? + assert_not_predicate citibank.association(:firm), :stale_target? end def test_reflect_the_most_recent_change @@ -1131,6 +1285,17 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase Column.create! record: record assert_equal 1, Column.count end + + def test_multiple_counter_cache_with_after_create_update + post = posts(:welcome) + parent = comments(:greetings) + + assert_difference "parent.reload.children_count", +1 do + assert_difference "post.reload.comments_count", +1 do + CommentWithAfterCreateUpdate.create(body: "foo", post: post, parent: parent) + end + end + end end class BelongsToWithForeignKeyTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb index 8a0e041864..88221b012e 100644 --- a/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb +++ b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/content" diff --git a/activerecord/test/cases/associations/callbacks_test.rb b/activerecord/test/cases/associations/callbacks_test.rb index 5fd2411f6f..25d55dc4c9 100644 --- a/activerecord/test/cases/associations/callbacks_test.rb +++ b/activerecord/test/cases/associations/callbacks_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/author" @@ -7,13 +9,13 @@ require "models/computer" require "models/company" class AssociationCallbacksTest < ActiveRecord::TestCase - fixtures :posts, :authors, :projects, :developers + fixtures :posts, :authors, :author_addresses, :projects, :developers def setup @david = authors(:david) @thinking = posts(:thinking) @authorless = posts(:authorless) - assert @david.post_log.empty? + assert_empty @david.post_log end def test_adding_macro_callbacks @@ -94,7 +96,7 @@ class AssociationCallbacksTest < ActiveRecord::TestCase def test_has_and_belongs_to_many_add_callback david = developers(:david) ar = projects(:active_record) - assert ar.developers_log.empty? + assert_empty ar.developers_log ar.developers_with_callbacks << david assert_equal ["before_adding#{david.id}", "after_adding#{david.id}"], ar.developers_log ar.developers_with_callbacks << david @@ -120,15 +122,15 @@ class AssociationCallbacksTest < ActiveRecord::TestCase assert_equal alice, dev assert_not_nil new_dev assert new_dev, "record should not have been saved" - assert_not alice.new_record? + assert_not_predicate alice, :new_record? end def test_has_and_belongs_to_many_after_add_called_after_save ar = projects(:active_record) - assert ar.developers_log.empty? + assert_empty ar.developers_log alice = Developer.new(name: "alice") ar.developers_with_callbacks << alice - assert_equal"after_adding#{alice.id}", ar.developers_log.last + assert_equal "after_adding#{alice.id}", ar.developers_log.last bob = ar.developers_with_callbacks.create(name: "bob") assert_equal "after_adding#{bob.id}", ar.developers_log.last @@ -141,7 +143,7 @@ class AssociationCallbacksTest < ActiveRecord::TestCase david = developers(:david) jamis = developers(:jamis) activerecord = projects(:active_record) - assert activerecord.developers_log.empty? + assert_empty activerecord.developers_log activerecord.developers_with_callbacks.delete(david) assert_equal ["before_removing#{david.id}", "after_removing#{david.id}"], activerecord.developers_log @@ -152,7 +154,7 @@ class AssociationCallbacksTest < ActiveRecord::TestCase def test_has_and_belongs_to_many_does_not_fire_callbacks_on_clear activerecord = projects(:active_record) - assert activerecord.developers_log.empty? + assert_empty activerecord.developers_log if activerecord.developers_with_callbacks.size == 0 activerecord.developers << developers(:david) activerecord.developers << developers(:jamis) @@ -161,7 +163,7 @@ class AssociationCallbacksTest < ActiveRecord::TestCase end activerecord.developers_with_callbacks.flat_map { |d| ["before_removing#{d.id}", "after_removing#{d.id}"] }.sort assert activerecord.developers_with_callbacks.clear - assert_predicate activerecord.developers_log, :empty? + assert_empty activerecord.developers_log end def test_has_many_and_belongs_to_many_callbacks_for_save_on_parent @@ -181,7 +183,7 @@ class AssociationCallbacksTest < ActiveRecord::TestCase @david.unchangeable_posts << @authorless rescue Exception end - assert @david.post_log.empty? + assert_empty @david.post_log assert_not_includes @david.unchangeable_posts, @authorless @david.reload assert_not_includes @david.unchangeable_posts, @authorless diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index ddb5c7a4aa..e717621928 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" @@ -12,7 +14,7 @@ require "models/vertex" require "models/edge" class CascadedEagerLoadingTest < ActiveRecord::TestCase - fixtures :authors, :mixins, :companies, :posts, :topics, :accounts, :comments, + fixtures :authors, :author_addresses, :mixins, :companies, :posts, :topics, :accounts, :comments, :categorizations, :people, :categories, :edges, :vertices def test_eager_association_loading_with_cascaded_two_levels @@ -34,18 +36,12 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_hmt_does_not_table_name_collide_when_joining_associations - assert_nothing_raised do - Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a - end authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a - assert_equal 1, assert_no_queries { authors.size } + assert_equal 3, assert_no_queries { authors.size } assert_equal 10, assert_no_queries { authors[0].comments.size } end def test_eager_association_loading_grafts_stashed_associations_to_correct_parent - assert_nothing_raised do - Person.eager_load(primary_contact: :primary_contact).where("primary_contacts_people_2.first_name = ?", "Susan").order("people.id").to_a - end assert_equal people(:michael), Person.eager_load(primary_contact: :primary_contact).where("primary_contacts_people_2.first_name = ?", "Susan").order("people.id").first end @@ -140,7 +136,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_multiple_stis_and_order - author = Author.all.merge!(includes: { posts: [ :special_comments , :very_special_comment ] }, order: ["authors.name", "comments.body", "very_special_comments_posts.body"], where: "posts.id = 4").first + author = Author.all.merge!(includes: { posts: [ :special_comments, :very_special_comment ] }, order: ["authors.name", "comments.body", "very_special_comments_posts.body"], where: "posts.id = 4").first assert_equal authors(:david), author assert_no_queries do author.posts.first.special_comments diff --git a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb index 4f0fe3236e..5fca972aee 100644 --- a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb +++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/tagging" @@ -6,30 +8,81 @@ module Namespaced class Post < ActiveRecord::Base self.table_name = "posts" has_one :tagging, as: :taggable, class_name: "Tagging" + + def self.polymorphic_name + sti_name + end end end -class EagerLoadIncludeFullStiClassNamesTest < ActiveRecord::TestCase +module PolymorphicFullStiClassNamesSharedTest def setup - generate_test_objects - end + @old_store_full_sti_class = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = store_full_sti_class - def generate_test_objects post = Namespaced::Post.create(title: "Great stuff", body: "This is not", author_id: 1) - Tagging.create(taggable: post) + @tagging = Tagging.create(taggable: post) + end + + def teardown + ActiveRecord::Base.store_full_sti_class = @old_store_full_sti_class end def test_class_names - old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + post = Namespaced::Post.find_by_title("Great stuff") + assert_nil post.tagging - ActiveRecord::Base.store_full_sti_class = false + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + post = Namespaced::Post.find_by_title("Great stuff") + assert_equal @tagging, post.tagging + end + + def test_class_names_with_includes + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class post = Namespaced::Post.includes(:tagging).find_by_title("Great stuff") assert_nil post.tagging - ActiveRecord::Base.store_full_sti_class = true + ActiveRecord::Base.store_full_sti_class = store_full_sti_class post = Namespaced::Post.includes(:tagging).find_by_title("Great stuff") - assert_instance_of Tagging, post.tagging - ensure - ActiveRecord::Base.store_full_sti_class = old + assert_equal @tagging, post.tagging + end + + def test_class_names_with_eager_load + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + post = Namespaced::Post.eager_load(:tagging).find_by_title("Great stuff") + assert_nil post.tagging + + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + post = Namespaced::Post.eager_load(:tagging).find_by_title("Great stuff") + assert_equal @tagging, post.tagging + end + + def test_class_names_with_find_by + post = Namespaced::Post.find_by_title("Great stuff") + + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + assert_nil Tagging.find_by(taggable: post) + + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + assert_equal @tagging, Tagging.find_by(taggable: post) end end + +class PolymorphicFullStiClassNamesTest < ActiveRecord::TestCase + include PolymorphicFullStiClassNamesSharedTest + + private + def store_full_sti_class + true + end +end + +class PolymorphicNonFullStiClassNamesTest < ActiveRecord::TestCase + include PolymorphicFullStiClassNamesSharedTest + + private + def store_full_sti_class + false + end +end 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 e9f551b6b2..c5b2b77bd4 100644 --- a/activerecord/test/cases/associations/eager_load_nested_include_test.rb +++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/tag" diff --git a/activerecord/test/cases/associations/eager_singularization_test.rb b/activerecord/test/cases/associations/eager_singularization_test.rb index 16eff15026..420a5a805b 100644 --- a/activerecord/test/cases/associations/eager_singularization_test.rb +++ b/activerecord/test/cases/associations/eager_singularization_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class EagerSingularizationTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index 11f4aae5b3..a1fba8dc66 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/tagging" @@ -38,6 +40,12 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_nil member.favourite_club end + def test_should_work_inverse_of_with_eager_load + author = authors(:david) + assert_same author, author.posts.first.author + assert_same author, author.posts.eager_load(:comments).first.author + end + def test_loading_with_one_association posts = Post.all.merge!(includes: :comments).to_a post = posts.find { |p| p.id == 1 } @@ -68,6 +76,71 @@ class EagerAssociationTest < ActiveRecord::TestCase "expected to find only david's posts" end + def test_loading_with_scope_including_joins + member = Member.first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.general_club + + member = Member.preload(:general_club).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.general_club + + member = Member.eager_load(:general_club).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.general_club + end + + def test_loading_association_with_same_table_joins + super_memberships = [memberships(:super_membership_of_boring_club)] + + member = Member.joins(:favourite_memberships).first + assert_equal members(:groucho), member + assert_equal super_memberships, member.super_memberships + + member = Member.joins(:favourite_memberships).preload(:super_memberships).first + assert_equal members(:groucho), member + assert_equal super_memberships, member.super_memberships + + member = Member.joins(:favourite_memberships).eager_load(:super_memberships).first + assert_equal members(:groucho), member + assert_equal super_memberships, member.super_memberships + end + + def test_loading_association_with_intersection_joins + member = Member.joins(:current_membership).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.club + assert_equal memberships(:membership_of_boring_club), member.current_membership + + member = Member.joins(:current_membership).preload(:club, :current_membership).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.club + assert_equal memberships(:membership_of_boring_club), member.current_membership + + member = Member.joins(:current_membership).eager_load(:club, :current_membership).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.club + assert_equal memberships(:membership_of_boring_club), member.current_membership + end + + def test_loading_associations_dont_leak_instance_state + assertions = ->(firm) { + assert_equal companies(:first_firm), firm + + assert_predicate firm.association(:readonly_account), :loaded? + assert_predicate firm.association(:accounts), :loaded? + + assert_equal accounts(:signals37), firm.readonly_account + assert_equal [accounts(:signals37)], firm.accounts + + assert_predicate firm.readonly_account, :readonly? + assert firm.accounts.none?(&:readonly?) + } + + assertions.call(Firm.preload(:readonly_account, :accounts).first) + assertions.call(Firm.eager_load(:readonly_account, :accounts).first) + end + def test_with_ordering list = Post.all.merge!(includes: :comments, order: "posts.id DESC").to_a [:other_by_mary, :other_by_bob, :misc_by_mary, :misc_by_bob, :eager_other, @@ -271,10 +344,7 @@ class EagerAssociationTest < ActiveRecord::TestCase end def test_loading_from_an_association_that_has_a_hash_of_conditions - assert_nothing_raised do - Author.all.merge!(includes: :hello_posts_with_hash_conditions).to_a - end - assert !Author.all.merge!(includes: :hello_posts_with_hash_conditions).find(authors(:david).id).hello_posts.empty? + assert_not_empty Author.all.merge!(includes: :hello_posts_with_hash_conditions).find(authors(:david).id).hello_posts end def test_loading_with_no_associations @@ -417,7 +487,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_eager_association_loading_with_belongs_to_and_order_string_with_quoted_table_name quoted_posts_id = Comment.connection.quote_table_name("posts") + "." + Comment.connection.quote_column_name("id") assert_nothing_raised do - Comment.includes(:post).references(:posts).order(quoted_posts_id) + Comment.includes(:post).references(:posts).order(Arel.sql(quoted_posts_id)) end end @@ -520,6 +590,14 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal [comments(:does_it_hurt)], assert_no_queries { author.special_post_comments } end + def test_preloading_has_many_through_with_implicit_source + authors = Author.includes(:very_special_comments).to_a + assert_no_queries do + special_comment_authors = authors.map { |author| [author.name, author.very_special_comments.size] } + assert_equal [["David", 1], ["Mary", 0], ["Bob", 0]], special_comment_authors + end + end + def test_eager_with_has_many_through_an_sti_join_model_with_conditions_on_both author = Author.all.merge!(includes: :special_nonexistent_post_comments, order: "authors.id").first assert_equal [], author.special_nonexistent_post_comments @@ -769,7 +847,7 @@ class EagerAssociationTest < ActiveRecord::TestCase Tagging.create!(taggable_type: "Post", taggable_id: post2.id, tag: tag) tag_with_includes = OrderedTag.includes(:tagged_posts).find(tag.id) - assert_equal(tag_with_includes.taggings.map(&:taggable).map(&:title), tag_with_includes.tagged_posts.map(&:title)) + assert_equal tag_with_includes.ordered_taggings.map(&:taggable).map(&:title), tag_with_includes.tagged_posts.map(&:title) end def test_eager_has_many_through_multiple_with_order @@ -851,23 +929,19 @@ class EagerAssociationTest < ActiveRecord::TestCase end end - def find_all_ordered(className, include = nil) - className.all.merge!(order: "#{className.table_name}.#{className.primary_key}", includes: include).to_a - end - def test_limited_eager_with_order assert_equal( posts(:thinking, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: "UPPER(posts.title)", limit: 2, offset: 1 + order: Arel.sql("UPPER(posts.title)"), limit: 2, offset: 1 ).to_a ) assert_equal( posts(:sti_post_and_comments, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: "UPPER(posts.title) DESC", limit: 2, offset: 1 + order: Arel.sql("UPPER(posts.title) DESC"), limit: 2, offset: 1 ).to_a ) end @@ -877,14 +951,14 @@ class EagerAssociationTest < ActiveRecord::TestCase posts(:thinking, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: ["UPPER(posts.title)", "posts.id"], limit: 2, offset: 1 + order: [Arel.sql("UPPER(posts.title)"), "posts.id"], limit: 2, offset: 1 ).to_a ) assert_equal( posts(:sti_post_and_comments, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: ["UPPER(posts.title) DESC", "posts.id"], limit: 2, offset: 1 + order: [Arel.sql("UPPER(posts.title) DESC"), "posts.id"], limit: 2, offset: 1 ).to_a ) end @@ -1059,7 +1133,7 @@ class EagerAssociationTest < ActiveRecord::TestCase end def test_load_with_sti_sharing_association - assert_queries(2) do #should not do 1 query per subclass + assert_queries(2) do # should not do 1 query per subclass Comment.includes(:post).to_a end end @@ -1094,12 +1168,6 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal authors(:david), assert_no_queries { posts[0].author } posts = assert_queries(2) do - Post.all.merge!(select: "distinct posts.*", includes: :author, joins: [:comments], where: "comments.body like 'Thank you%'", order: "posts.id").to_a - end - assert_equal [posts(:welcome)], posts - assert_equal authors(:david), assert_no_queries { posts[0].author } - - posts = assert_queries(2) do Post.all.merge!(includes: :author, joins: { taggings: :tag }, where: "tags.name = 'General'", order: "posts.id").to_a end assert_equal posts(:welcome, :thinking), posts @@ -1210,6 +1278,7 @@ class EagerAssociationTest < ActiveRecord::TestCase client = assert_queries(2) { Client.preload(:firm).find(c.id) } assert_no_queries { assert_nil client.firm } + assert_equal c.client_of, client.client_of end def test_preloading_empty_belongs_to_polymorphic @@ -1217,6 +1286,7 @@ class EagerAssociationTest < ActiveRecord::TestCase tagging = assert_queries(2) { Tagging.preload(:taggable).find(t.id) } assert_no_queries { assert_nil tagging.taggable } + assert_equal t.taggable_id, tagging.taggable_id end def test_preloading_through_empty_belongs_to @@ -1295,6 +1365,11 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal projects.last.mentor.developers.first.contracts, projects.last.developers.last.contracts end + def test_preloading_has_many_through_with_custom_scope + project = Project.includes(:developers_named_david_with_hash_conditions).find(projects(:active_record).id) + assert_equal [developers(:david)], project.developers_named_david_with_hash_conditions + end + test "scoping with a circular preload" do assert_equal Comment.find(1), Comment.preload(post: :comments).scoping { Comment.find(1) } end @@ -1363,6 +1438,7 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_nothing_raised do authors(:david).essays.includes(:writer).any? authors(:david).essays.includes(:writer).exists? + authors(:david).essays.includes(:owner).where("name IS NOT NULL").exists? end end @@ -1430,51 +1506,51 @@ class EagerAssociationTest < ActiveRecord::TestCase test "preloading readonly association" do # has-one firm = Firm.where(id: "1").preload(:readonly_account).first! - assert firm.readonly_account.readonly? + assert_predicate firm.readonly_account, :readonly? # has_and_belongs_to_many project = Project.where(id: "2").preload(:readonly_developers).first! - assert project.readonly_developers.first.readonly? + assert_predicate project.readonly_developers.first, :readonly? # has-many :through david = Author.where(id: "1").preload(:readonly_comments).first! - assert david.readonly_comments.first.readonly? + assert_predicate david.readonly_comments.first, :readonly? end test "eager-loading non-readonly association" do # has_one firm = Firm.where(id: "1").eager_load(:account).first! - assert_not firm.account.readonly? + assert_not_predicate firm.account, :readonly? # has_and_belongs_to_many project = Project.where(id: "2").eager_load(:developers).first! - assert_not project.developers.first.readonly? + assert_not_predicate project.developers.first, :readonly? # has_many :through david = Author.where(id: "1").eager_load(:comments).first! - assert_not david.comments.first.readonly? + assert_not_predicate david.comments.first, :readonly? # belongs_to post = Post.where(id: "1").eager_load(:author).first! - assert_not post.author.readonly? + assert_not_predicate post.author, :readonly? end test "eager-loading readonly association" do # has-one firm = Firm.where(id: "1").eager_load(:readonly_account).first! - assert firm.readonly_account.readonly? + assert_predicate firm.readonly_account, :readonly? # has_and_belongs_to_many project = Project.where(id: "2").eager_load(:readonly_developers).first! - assert project.readonly_developers.first.readonly? + assert_predicate project.readonly_developers.first, :readonly? # has-many :through david = Author.where(id: "1").eager_load(:readonly_comments).first! - assert david.readonly_comments.first.readonly? + assert_predicate david.readonly_comments.first, :readonly? # belongs_to post = Post.where(id: "1").eager_load(:readonly_author).first! - assert post.readonly_author.readonly? + assert_predicate post.readonly_author, :readonly? end test "preloading a polymorphic association with references to the associated table" do @@ -1487,9 +1563,50 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal posts(:welcome), post end + test "eager-loading with a polymorphic association won't work consistently" do + assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).to_a } + assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).count } + assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).exists? } + end + # CollectionProxy#reader is expensive, so the preloader avoids calling it. test "preloading has_many_through association avoids calling association.reader" do - ActiveRecord::Associations::HasManyAssociation.any_instance.expects(:reader).never - Author.preload(:readonly_comments).first! + assert_not_called_on_instance_of(ActiveRecord::Associations::HasManyAssociation, :reader) do + Author.preload(:readonly_comments).first! + end + end + + test "preloading through a polymorphic association doesn't require the association to exist" do + sponsors = [] + assert_queries 5 do + sponsors = Sponsor.where(sponsorable_id: 1).preload(sponsorable: [:post, :membership]).to_a + end + # check the preload worked + assert_queries 0 do + sponsors.map(&:sponsorable).map { |s| s.respond_to?(:posts) ? s.post.author : s.membership } + end + end + + test "preloading a regular association through a polymorphic association doesn't require the association to exist on all types" do + sponsors = [] + assert_queries 6 do + sponsors = Sponsor.where(sponsorable_id: 1).preload(sponsorable: [{ post: :first_comment }, :membership]).to_a + end + # check the preload worked + assert_queries 0 do + sponsors.map(&:sponsorable).map { |s| s.respond_to?(:posts) ? s.post.author : s.membership } + end end + + test "preloading a regular association with a typo through a polymorphic association still raises" do + # this test contains an intentional typo of first -> fist + assert_raises(ActiveRecord::AssociationNotFoundError) do + Sponsor.where(sponsorable_id: 1).preload(sponsorable: [{ post: :fist_comment }, :membership]).to_a + end + end + + private + def find_all_ordered(klass, include = nil) + klass.order("#{klass.table_name}.#{klass.primary_key}").includes(include).to_a + end end diff --git a/activerecord/test/cases/associations/extension_test.rb b/activerecord/test/cases/associations/extension_test.rb index 974a3080d4..5eacb5a3d8 100644 --- a/activerecord/test/cases/associations/extension_test.rb +++ b/activerecord/test/cases/associations/extension_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" @@ -36,6 +38,11 @@ class AssociationsExtensionsTest < ActiveRecord::TestCase assert_equal comments(:greetings), posts(:welcome).comments.not_again.find_most_recent end + def test_extension_with_dirty_target + comment = posts(:welcome).comments.build(body: "New comment") + assert_equal comment, posts(:welcome).comments.with_content("New comment") + end + def test_marshalling_extensions david = developers(:david) assert_equal projects(:action_controller), david.projects.find_most_recent @@ -73,6 +80,12 @@ class AssociationsExtensionsTest < ActiveRecord::TestCase assert_equal post.association(:comments), post.comments.where("1=1").the_association end + def test_association_with_default_scope + assert_raises OopsError do + posts(:welcome).comments.destroy_all + end + end + private def extend!(model) diff --git a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb index d6b595d7e7..482302055d 100644 --- a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/developer" require "models/computer" @@ -86,12 +88,6 @@ class DeveloperWithSymbolClassName < Developer has_and_belongs_to_many :projects, class_name: :ProjectWithSymbolsForKeys end -ActiveSupport::Deprecation.silence do - class DeveloperWithConstantClassName < Developer - has_and_belongs_to_many :projects, class_name: ProjectWithSymbolsForKeys - end -end - class DeveloperWithExtendOption < Developer module NamedExtension def category @@ -111,6 +107,21 @@ class ProjectUnscopingDavidDefaultScope < ActiveRecord::Base association_foreign_key: "developer_id" end +class Kitchen < ActiveRecord::Base + has_one :sink +end + +class Sink < ActiveRecord::Base + has_and_belongs_to_many :sources, join_table: :edges + belongs_to :kitchen + accepts_nested_attributes_for :kitchen +end + +class Source < ActiveRecord::Base + self.table_name = "men" + has_and_belongs_to_many :sinks, join_table: :edges +end + class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :categories, :posts, :categories_posts, :developers, :projects, :developers_projects, :parrots, :pirates, :parrots_pirates, :treasures, :price_estimates, :tags, :taggings, :computers @@ -169,11 +180,11 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_has_and_belongs_to_many david = Developer.find(1) - assert !david.projects.empty? + assert_not_empty david.projects assert_equal 2, david.projects.size active_record = Project.find(1) - assert !active_record.developers.empty? + assert_not_empty active_record.developers assert_equal 3, active_record.developers.size assert_includes active_record.developers, david end @@ -251,10 +262,10 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase no_of_projects = Project.count aredridel = Developer.new("name" => "Aredridel") aredridel.projects.concat([Project.find(1), p = Project.new("name" => "Projekt")]) - assert !aredridel.persisted? - assert !p.persisted? + assert_not_predicate aredridel, :persisted? + assert_not_predicate p, :persisted? assert aredridel.save - assert aredridel.persisted? + assert_predicate aredridel, :persisted? assert_equal no_of_devels + 1, Developer.count assert_equal no_of_projects + 1, Project.count assert_equal 2, aredridel.projects.size @@ -300,14 +311,14 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_build devel = Developer.find(1) proj = assert_no_queries(ignore_none: false) { devel.projects.build("name" => "Projekt") } - assert !devel.projects.loaded? + assert_not_predicate devel.projects, :loaded? assert_equal devel.projects.last, proj - assert devel.projects.loaded? + assert_predicate devel.projects, :loaded? - assert !proj.persisted? + assert_not_predicate proj, :persisted? devel.save - assert proj.persisted? + assert_predicate proj, :persisted? assert_equal devel.projects.last, proj assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated end @@ -315,14 +326,14 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_new_aliased_to_build devel = Developer.find(1) proj = assert_no_queries(ignore_none: false) { devel.projects.new("name" => "Projekt") } - assert !devel.projects.loaded? + assert_not_predicate devel.projects, :loaded? assert_equal devel.projects.last, proj - assert devel.projects.loaded? + assert_predicate devel.projects, :loaded? - assert !proj.persisted? + assert_not_predicate proj, :persisted? devel.save - assert proj.persisted? + assert_predicate proj, :persisted? assert_equal devel.projects.last, proj assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated end @@ -332,10 +343,10 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase devel.projects.build(name: "Make bed") proj2 = devel.projects.build(name: "Lie in it") assert_equal devel.projects.last, proj2 - assert !proj2.persisted? + assert_not_predicate proj2, :persisted? devel.save - assert devel.persisted? - assert proj2.persisted? + assert_predicate devel, :persisted? + assert_predicate proj2, :persisted? assert_equal devel.projects.last, proj2 assert_equal Developer.find_by_name("Marcel").projects.last, proj2 # prove join table is updated end @@ -343,40 +354,27 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_create devel = Developer.find(1) proj = devel.projects.create("name" => "Projekt") - assert !devel.projects.loaded? + assert_not_predicate devel.projects, :loaded? assert_equal devel.projects.last, proj - assert !devel.projects.loaded? + assert_not_predicate devel.projects, :loaded? - assert proj.persisted? + assert_predicate proj, :persisted? assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated end - def test_create_by_new_record - devel = Developer.new(name: "Marcel", salary: 75000) - devel.projects.build(name: "Make bed") - proj2 = devel.projects.build(name: "Lie in it") - assert_equal devel.projects.last, proj2 - assert !proj2.persisted? - devel.save - assert devel.persisted? - assert proj2.persisted? - assert_equal devel.projects.last, proj2 - assert_equal Developer.find_by_name("Marcel").projects.last, proj2 # prove join table is updated - end - def test_creation_respects_hash_condition # in Oracle '' is saved as null therefore need to save ' ' in not null column post = categories(:general).post_with_conditions.build(body: " ") - assert post.save - assert_equal "Yet Another Testing Title", post.title + assert post.save + assert_equal "Yet Another Testing Title", post.title # in Oracle '' is saved as null therefore need to save ' ' in not null column another_post = categories(:general).post_with_conditions.create(body: " ") - assert another_post.persisted? - assert_equal "Yet Another Testing Title", another_post.title + assert_predicate another_post, :persisted? + assert_equal "Yet Another Testing Title", another_post.title end def test_distinct_after_the_fact @@ -443,10 +441,10 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_removing_associations_on_destroy david = DeveloperWithBeforeDestroyRaise.find(1) - assert !david.projects.empty? + assert_not_empty david.projects david.destroy - assert david.projects.empty? - assert DeveloperWithBeforeDestroyRaise.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = 1").empty? + assert_empty david.projects + assert_empty DeveloperWithBeforeDestroyRaise.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = 1") end def test_destroying @@ -461,7 +459,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id} AND project_id = #{project.id}") - assert join_records.empty? + assert_empty join_records assert_equal 1, david.reload.projects.size assert_equal 1, david.projects.reload.size @@ -477,7 +475,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id}") - assert join_records.empty? + assert_empty join_records assert_equal 0, david.reload.projects.size assert_equal 0, david.projects.reload.size @@ -486,23 +484,23 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_destroy_all david = Developer.find(1) david.projects.reload - assert !david.projects.empty? + assert_not_empty david.projects assert_no_difference "Project.count" do david.projects.destroy_all end join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id}") - assert join_records.empty? + assert_empty join_records - assert david.projects.empty? - assert david.projects.reload.empty? + assert_empty david.projects + assert_empty david.projects.reload end def test_destroy_associations_destroys_multiple_associations george = parrots(:george) - assert !george.pirates.empty? - assert !george.treasures.empty? + assert_not_empty george.pirates + assert_not_empty george.treasures assert_no_difference "Pirate.count" do assert_no_difference "Treasure.count" do @@ -511,12 +509,12 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end join_records = Parrot.connection.select_all("SELECT * FROM parrots_pirates WHERE parrot_id = #{george.id}") - assert join_records.empty? - assert george.pirates.reload.empty? + assert_empty join_records + assert_empty george.pirates.reload join_records = Parrot.connection.select_all("SELECT * FROM parrots_treasures WHERE parrot_id = #{george.id}") - assert join_records.empty? - assert george.treasures.reload.empty? + assert_empty join_records + assert_empty george.treasures.reload end def test_associations_with_conditions @@ -549,7 +547,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase developer = project.developers.first assert_no_queries(ignore_none: false) do - assert project.developers.loaded? + assert_predicate project.developers, :loaded? assert_includes project.developers, developer end end @@ -559,19 +557,19 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase developer = project.developers.first project.reload - assert ! project.developers.loaded? + assert_not_predicate project.developers, :loaded? assert_queries(1) do assert_includes project.developers, developer end - assert ! project.developers.loaded? + assert_not_predicate project.developers, :loaded? end def test_include_returns_false_for_non_matching_record_to_verify_scoping project = projects(:active_record) developer = Developer.create name: "Bryan", salary: 50_000 - assert ! project.developers.loaded? - assert ! project.developers.include?(developer) + assert_not_predicate project.developers, :loaded? + assert_not project.developers.include?(developer) end def test_find_with_merged_options @@ -664,7 +662,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase assert_includes developer.sym_special_projects, sp end - def test_update_attributes_after_push_without_duplicate_join_table_rows + def test_update_columns_after_push_without_duplicate_join_table_rows developer = Developer.new("name" => "Kano") project = SpecialProject.create("name" => "Special Project") assert developer.save @@ -699,24 +697,13 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end def test_join_table_alias - # FIXME: `references` has no impact on the aliases generated for the join - # query. The fact that we pass `:developers_projects_join` to `references` - # and that the SQL string contains `developers_projects_join` is merely a - # coincidence. assert_equal( 3, - Developer.references(:developers_projects_join).merge( - includes: { projects: :developers }, - where: "projects_developers_projects_join.joined_on IS NOT NULL" - ).to_a.size + Developer.includes(projects: :developers).where.not("projects_developers_projects_join.joined_on": nil).to_a.size ) end def test_join_with_group - # FIXME: `references` has no impact on the aliases generated for the join - # query. The fact that we pass `:developers_projects_join` to `references` - # and that the SQL string contains `developers_projects_join` is merely a - # coincidence. group = Developer.columns.inject([]) do |g, c| g << "developers.#{c.name}" g << "developers_projects_2.#{c.name}" @@ -725,10 +712,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase assert_equal( 3, - Developer.references(:developers_projects_join).merge( - includes: { projects: :developers }, where: "projects_developers_projects_join.joined_on IS NOT NULL", - group: group.join(",") - ).to_a.size + Developer.includes(projects: :developers).where.not("projects_developers_projects_join.joined_on": nil).group(group.join(",")).to_a.size ) end @@ -765,9 +749,9 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_get_ids_for_unloaded_associations_does_not_load_them developer = developers(:david) - assert !developer.projects.loaded? + assert_not_predicate developer.projects, :loaded? assert_equal projects(:active_record, :action_controller).map(&:id).sort, developer.project_ids.sort - assert !developer.projects.loaded? + assert_not_predicate developer.projects, :loaded? end def test_assign_ids @@ -797,7 +781,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end def test_has_many_through_polymorphic_has_manys_works - assert_equal [10, 20].to_set, pirates(:redbeard).treasure_estimates.map(&:price).to_set + assert_equal ["$10.00", "$20.00"].to_set, pirates(:redbeard).treasure_estimates.map(&:price).to_set end def test_symbols_as_keys @@ -939,7 +923,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase assert_not_nil Developer._reflections["shared_computers"] # Checking the fixture for named association is important here, because it's the only way # we've been able to reproduce this bug - assert_not_nil File.read(File.expand_path("../../../fixtures/developers.yml", __FILE__)).index("shared_computers") + assert_not_nil File.read(File.expand_path("../../fixtures/developers.yml", __dir__)).index("shared_computers") assert_equal developers(:david).shared_computers.first, computers(:laptop) end @@ -950,13 +934,6 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end end - def test_with_constant_class_name - assert_nothing_raised do - developer = DeveloperWithConstantClassName.new - developer.projects - end - end - def test_alternate_database professor = Professor.create(name: "Plum") course = Course.create(name: "Forensics") @@ -1021,4 +998,9 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase ActiveRecord::Base.partial_writes = original_partial_writes end end + + def test_has_and_belongs_to_many_with_belongs_to + sink = Sink.create! kitchen: Kitchen.new, sources: [Source.new] + assert_equal 1, sink.sources.count + 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 ede3a44090..a2f6174dc1 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/developer" require "models/computer" @@ -25,7 +27,6 @@ require "models/categorization" require "models/minivan" require "models/speedometer" require "models/reference" -require "models/job" require "models/college" require "models/student" require "models/pirate" @@ -40,7 +41,7 @@ require "models/zine" require "models/interest" class HasManyAssociationsTestForReorderWithJoinDependency < ActiveRecord::TestCase - fixtures :authors, :posts, :comments + fixtures :authors, :author_addresses, :posts, :comments def test_should_generate_valid_sql author = authors(:david) @@ -51,33 +52,44 @@ class HasManyAssociationsTestForReorderWithJoinDependency < ActiveRecord::TestCa end class HasManyAssociationsTestPrimaryKeys < ActiveRecord::TestCase - fixtures :authors, :essays, :subscribers, :subscriptions, :people + fixtures :authors, :author_addresses, :essays, :subscribers, :subscriptions, :people def test_custom_primary_key_on_new_record_should_fetch_with_query subscriber = Subscriber.new(nick: "webster132") - assert !subscriber.subscriptions.loaded? + assert_not_predicate subscriber.subscriptions, :loaded? assert_queries 1 do assert_equal 2, subscriber.subscriptions.size end - assert_equal subscriber.subscriptions, Subscription.where(subscriber_id: "webster132") + assert_equal Subscription.where(subscriber_id: "webster132"), subscriber.subscriptions end def test_association_primary_key_on_new_record_should_fetch_with_query author = Author.new(name: "David") - assert !author.essays.loaded? + assert_not_predicate author.essays, :loaded? assert_queries 1 do assert_equal 1, author.essays.size end - assert_equal author.essays, Essay.where(writer_id: "David") + assert_equal Essay.where(writer_id: "David"), author.essays end def test_has_many_custom_primary_key david = authors(:david) - assert_equal david.essays, Essay.where(writer_id: "David") + assert_equal Essay.where(writer_id: "David"), david.essays + end + + def test_ids_on_unloaded_association_with_custom_primary_key + david = people(:david) + assert_equal Essay.where(writer_id: "David").pluck(:id), david.essay_ids + end + + def test_ids_on_loaded_association_with_custom_primary_key + david = people(:david) + david.essays.load + assert_equal Essay.where(writer_id: "David").pluck(:id), david.essay_ids end def test_has_many_assignment_with_custom_primary_key @@ -90,7 +102,7 @@ class HasManyAssociationsTestPrimaryKeys < ActiveRecord::TestCase def test_blank_custom_primary_key_on_new_record_should_not_run_queries author = Author.new - assert !author.essays.loaded? + assert_not_predicate author.essays, :loaded? assert_queries 0 do assert_equal 0, author.essays.size @@ -100,8 +112,8 @@ end class HasManyAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :categories, :companies, :developers, :projects, - :developers_projects, :topics, :authors, :comments, - :posts, :readers, :taggings, :cars, :jobs, :tags, + :developers_projects, :topics, :authors, :author_addresses, :comments, + :posts, :readers, :taggings, :cars, :tags, :categorizations, :zines, :interests def setup @@ -188,7 +200,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase part.reload assert_nil part.ship - assert !part.updated_at_changed? + assert_not_predicate part, :updated_at_changed? end def test_create_from_association_should_respect_default_scope @@ -241,6 +253,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal "defaulty", bulb.name end + def test_build_from_association_sets_inverse_instance + car = Car.new(name: "honda") + + bulb = car.bulbs.build + assert_equal car, bulb.car + end + def test_do_not_call_callbacks_for_delete_all car = Car.create(name: "honda") car.funky_bulbs.create! @@ -357,6 +376,27 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal invoice.id, line_item.invoice_id end + class SpecialAuthor < ActiveRecord::Base + self.table_name = "authors" + has_many :books, class_name: "SpecialBook", foreign_key: :author_id + end + + class SpecialBook < ActiveRecord::Base + self.table_name = "books" + + belongs_to :author + enum read_status: { unread: 0, reading: 2, read: 3, forgotten: nil } + end + + def test_association_enum_works_properly + author = SpecialAuthor.create!(name: "Test") + book = SpecialBook.create!(read_status: "reading") + author.books << book + + assert_equal "reading", book.read_status + assert_not_equal 0, SpecialAuthor.joins(:books).where(books: { read_status: "reading" }).count + end + # When creating objects on the association, we must not do it within a scope (even though it # would be convenient), because this would cause that scope to be applied to any callbacks etc. def test_build_and_create_should_not_happen_within_scope @@ -424,7 +464,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase new_clients << company.clients_of_firm.build(name: "Another Client III") end - assert_not company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? assert_queries(1) do assert_same new_clients[0], company.clients_of_firm.third assert_same new_clients[1], company.clients_of_firm.fourth @@ -444,7 +484,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase new_clients << company.clients_of_firm.build(name: "Another Client III") end - assert_not company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? assert_queries(1) do assert_same new_clients[0], company.clients_of_firm.third! assert_same new_clients[1], company.clients_of_firm.fourth! @@ -456,7 +496,10 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_create_resets_cached_counters + Reader.delete_all + person = Person.create!(first_name: "tenderlove") + post = Post.first assert_equal [], person.readers @@ -474,8 +517,6 @@ class HasManyAssociationsTest < ActiveRecord::TestCase person = Person.new person.first_name = "Naruto" person.references << Reference.new - person.id = 10 - person.references person.save! assert_equal 1, person.references.update_all(favourite: true) end @@ -484,27 +525,24 @@ class HasManyAssociationsTest < ActiveRecord::TestCase person = Person.new person.first_name = "Sasuke" person.references << Reference.new - person.id = 10 - person.references person.save! assert_predicate person.references, :exists? end - # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first def test_counting_with_counter_sql - assert_equal 3, Firm.all.merge!(order: "id").first.clients.count + assert_equal 3, Firm.first.clients.count end def test_counting - assert_equal 3, Firm.all.merge!(order: "id").first.plain_clients.count + assert_equal 3, Firm.first.plain_clients.count end def test_counting_with_single_hash - assert_equal 1, Firm.all.merge!(order: "id").first.plain_clients.where(name: "Microsoft").count + assert_equal 1, Firm.first.plain_clients.where(name: "Microsoft").count end def test_counting_with_column_name_and_hash - assert_equal 3, Firm.all.merge!(order: "id").first.plain_clients.count(:name) + assert_equal 3, Firm.first.plain_clients.count(:name) end def test_counting_with_association_limit @@ -514,7 +552,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_finding - assert_equal 3, Firm.all.merge!(order: "id").first.clients.length + assert_equal 3, Firm.first.clients.length end def test_finding_array_compatibility @@ -554,17 +592,25 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_taking_with_a_number + klass = Class.new(Author) do + has_many :posts, -> { order(:id) } + + def self.name + "Author" + end + end + # taking from unloaded Relation - bob = Author.find(authors(:bob).id) + bob = klass.find(authors(:bob).id) new_post = bob.posts.build - assert_not bob.posts.loaded? + assert_not_predicate bob.posts, :loaded? assert_equal [posts(:misc_by_bob)], bob.posts.take(1) assert_equal [posts(:misc_by_bob), posts(:other_by_bob)], bob.posts.take(2) assert_equal [posts(:misc_by_bob), posts(:other_by_bob), new_post], bob.posts.take(3) # taking from loaded Relation bob.posts.load - assert bob.posts.loaded? + assert_predicate bob.posts, :loaded? assert_equal [posts(:misc_by_bob)], bob.posts.take(1) assert_equal [posts(:misc_by_bob), posts(:other_by_bob)], bob.posts.take(2) assert_equal [posts(:misc_by_bob), posts(:other_by_bob), new_post], bob.posts.take(3) @@ -586,27 +632,27 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_finding_default_orders - assert_equal "Summit", Firm.all.merge!(order: "id").first.clients.first.name + assert_equal "Summit", Firm.first.clients.first.name end def test_finding_with_different_class_name_and_order - assert_equal "Apex", Firm.all.merge!(order: "id").first.clients_sorted_desc.first.name + assert_equal "Apex", Firm.first.clients_sorted_desc.first.name end def test_finding_with_foreign_key - assert_equal "Microsoft", Firm.all.merge!(order: "id").first.clients_of_firm.first.name + assert_equal "Microsoft", Firm.first.clients_of_firm.first.name end def test_finding_with_condition - assert_equal "Microsoft", Firm.all.merge!(order: "id").first.clients_like_ms.first.name + assert_equal "Microsoft", Firm.first.clients_like_ms.first.name end def test_finding_with_condition_hash - assert_equal "Microsoft", Firm.all.merge!(order: "id").first.clients_like_ms_with_hash_conditions.first.name + assert_equal "Microsoft", Firm.first.clients_like_ms_with_hash_conditions.first.name end def test_finding_using_primary_key - assert_equal "Summit", Firm.all.merge!(order: "id").first.clients_using_primary_key.first.name + assert_equal "Summit", Firm.first.clients_using_primary_key.first.name end def test_update_all_on_association_accessed_before_save @@ -629,7 +675,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_find_ids - firm = Firm.all.merge!(order: "id").first + firm = Firm.first assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find } @@ -649,7 +695,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_find_one_message_on_primary_key - firm = Firm.all.merge!(order: "id").first + firm = Firm.first e = assert_raises(ActiveRecord::RecordNotFound) do firm.clients.find(0) @@ -675,7 +721,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_find_all - firm = Firm.all.merge!(order: "id").first + firm = Firm.first assert_equal 3, firm.clients.where("#{QUOTED_TYPE} = 'Client'").to_a.length assert_equal 1, firm.clients.where("name = 'Summit'").to_a.length end @@ -683,13 +729,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_find_each firm = companies(:first_firm) - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_queries(4) do firm.clients.find_each(batch_size: 1) { |c| assert_equal firm.id, c.firm_id } end - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_find_each_with_conditions @@ -702,13 +748,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end end - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_find_in_batches firm = companies(:first_firm) - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_queries(2) do firm.clients.find_in_batches(batch_size: 2) do |clients| @@ -716,29 +762,63 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end end - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_find_all_sanitized - # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first - firm = Firm.all.merge!(order: "id").first + firm = Firm.first summit = firm.clients.where("name = 'Summit'").to_a assert_equal summit, firm.clients.where("name = ?", "Summit").to_a assert_equal summit, firm.clients.where("name = :name", name: "Summit").to_a end def test_find_first - firm = Firm.all.merge!(order: "id").first + firm = Firm.first client2 = Client.find(2) assert_equal firm.clients.first, firm.clients.order("id").first assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = 'Client'").order("id").first end def test_find_first_sanitized - firm = Firm.all.merge!(order: "id").first + firm = Firm.first client2 = Client.find(2) - assert_equal client2, firm.clients.merge!(where: ["#{QUOTED_TYPE} = ?", "Client"], order: "id").first - assert_equal client2, firm.clients.merge!(where: ["#{QUOTED_TYPE} = :type", { type: "Client" }], order: "id").first + assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = ?", "Client").first + assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = :type", type: "Client").first + end + + def test_find_first_after_reset_scope + firm = Firm.first + collection = firm.clients + + original_object = collection.first + assert_same original_object, collection.first, "Expected second call to #first to cache the same object" + + # It should return a different object, since the association has been reloaded + assert_not_same original_object, firm.clients.first, "Expected #first to return a new object" + end + + def test_find_first_after_reset + firm = Firm.first + collection = firm.clients + + original_object = collection.first + assert_same original_object, collection.first, "Expected second call to #first to cache the same object" + collection.reset + + # It should return a different object, since the association has been reloaded + assert_not_same original_object, collection.first, "Expected #first after #reset to return a new object" + end + + def test_find_first_after_reload + firm = Firm.first + collection = firm.clients + + original_object = collection.first + assert_same original_object, collection.first, "Expected second call to #first to cache the same object" + collection.reload + + # It should return a different object, since the association has been reloaded + assert_not_same original_object, collection.first, "Expected #first after #reload to return a new object" end def test_find_all_with_include_and_conditions @@ -767,7 +847,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_find_scoped_grouped_having - assert_equal 1, authors(:david).popular_grouped_posts.length + assert_equal 2, authors(:david).popular_grouped_posts.length assert_equal 0, authors(:mary).popular_grouped_posts.length end @@ -783,6 +863,12 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal [1], posts(:welcome).comments.select { |c| c.id == 1 }.map(&:id) end + def test_select_with_block_and_dirty_target + assert_equal 2, posts(:welcome).comments.select { true }.size + posts(:welcome).comments.build + assert_equal 3, posts(:welcome).comments.select { true }.size + end + def test_select_without_foreign_key assert_equal companies(:first_firm).accounts.first.credit_limit, companies(:first_firm).accounts.select(:credit_limit).first.credit_limit end @@ -827,7 +913,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_create_with_bang_on_has_many_raises_when_record_not_saved assert_raise(ActiveRecord::RecordInvalid) do - firm = Firm.all.merge!(order: "id").first + firm = Firm.first firm.plain_clients.create! end end @@ -885,20 +971,20 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_new_aliased_to_build company = companies(:first_firm) new_client = assert_no_queries(ignore_none: false) { company.clients_of_firm.new("name" => "Another Client") } - assert !company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? assert_equal "Another Client", new_client.name - assert !new_client.persisted? + assert_not_predicate new_client, :persisted? assert_equal new_client, company.clients_of_firm.last end def test_build company = companies(:first_firm) new_client = assert_no_queries(ignore_none: false) { company.clients_of_firm.build("name" => "Another Client") } - assert !company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? assert_equal "Another Client", new_client.name - assert !new_client.persisted? + assert_not_predicate new_client, :persisted? assert_equal new_client, company.clients_of_firm.last end @@ -912,9 +998,29 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_collection_not_empty_after_building company = companies(:first_firm) - assert_predicate company.contracts, :empty? + assert_empty company.contracts company.contracts.build - assert_not_predicate company.contracts, :empty? + assert_not_empty company.contracts + end + + def test_collection_size_with_dirty_target + post = posts(:thinking) + assert_equal [], post.reader_ids + assert_equal 0, post.readers.size + post.readers.reset + post.readers.build + assert_equal [nil], post.reader_ids + assert_equal 1, post.readers.size + end + + def test_collection_empty_with_dirty_target + post = posts(:thinking) + assert_equal [], post.reader_ids + assert_empty post.readers + post.readers.reset + post.readers.build + assert_equal [nil], post.reader_ids + assert_not_empty post.readers end def test_collection_size_twice_for_regressions @@ -938,7 +1044,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_build_followed_by_save_does_not_load_target companies(:first_firm).clients_of_firm.build("name" => "Another Client") assert companies(:first_firm).save - assert !companies(:first_firm).clients_of_firm.loaded? + assert_not_predicate companies(:first_firm).clients_of_firm, :loaded? end def test_build_without_loading_association @@ -958,10 +1064,10 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_build_via_block company = companies(:first_firm) new_client = assert_no_queries(ignore_none: false) { company.clients_of_firm.build { |client| client.name = "Another Client" } } - assert !company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? assert_equal "Another Client", new_client.name - assert !new_client.persisted? + assert_not_predicate new_client, :persisted? assert_equal new_client, company.clients_of_firm.last end @@ -999,7 +1105,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_predicate companies(:first_firm).clients_of_firm, :loaded? new_client = companies(:first_firm).clients_of_firm.create("name" => "Another Client") - assert new_client.persisted? + assert_predicate new_client, :persisted? assert_equal new_client, companies(:first_firm).clients_of_firm.last assert_equal new_client, companies(:first_firm).clients_of_firm.reload.last end @@ -1012,7 +1118,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_create_followed_by_save_does_not_load_target companies(:first_firm).clients_of_firm.create("name" => "Another Client") assert companies(:first_firm).save - assert !companies(:first_firm).clients_of_firm.loaded? + assert_not_predicate companies(:first_firm).clients_of_firm, :loaded? end def test_deleting @@ -1038,7 +1144,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase # option is not given on the association. ship = Ship.create(name: "Countless", treasures_count: 10) - assert_not Ship.reflect_on_association(:treasures).has_cached_counter? + assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter? # Count should come from sql count() of treasures rather than treasures_count attribute assert_equal ship.treasures.size, 0 @@ -1129,7 +1235,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_calling_empty_with_counter_cache post = posts(:welcome) assert_queries(0) do - assert_not post.comments.empty? + assert_not_empty post.comments end end @@ -1141,20 +1247,20 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end end - def test_calling_update_attributes_on_id_changes_the_counter_cache + def test_calling_update_on_id_changes_the_counter_cache topic = Topic.order("id ASC").first original_count = topic.replies.to_a.size assert_equal original_count, topic.replies_count first_reply = topic.replies.first - first_reply.update_attributes(parent_id: nil) + first_reply.update(parent_id: nil) assert_equal original_count - 1, topic.reload.replies_count - first_reply.update_attributes(parent_id: topic.id) + first_reply.update(parent_id: topic.id) assert_equal original_count, topic.reload.replies_count end - def test_calling_update_attributes_changing_ids_doesnt_change_counter_cache + def test_calling_update_changing_ids_doesnt_change_counter_cache topic1 = Topic.find(1) topic2 = Topic.find(3) original_count1 = topic1.replies.to_a.size @@ -1163,11 +1269,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase reply1 = topic1.replies.first reply2 = topic2.replies.first - reply1.update_attributes(parent_id: topic2.id) + reply1.update(parent_id: topic2.id) assert_equal original_count1 - 1, topic1.reload.replies_count assert_equal original_count2 + 1, topic2.reload.replies_count - reply2.update_attributes(parent_id: topic1.id) + reply2.update(parent_id: topic1.id) assert_equal original_count1, topic1.reload.replies_count assert_equal original_count2, topic2.reload.replies_count end @@ -1349,7 +1455,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase Client.create(client_of: firm.id, name: "SmallTime Inc.") # only one of two clients is included in the association due to the :conditions key assert_equal 2, Client.where(client_of: firm.id).size - assert_equal 1, firm.dependent_sanitized_conditional_clients_of_firm.size + assert_equal 1, firm.dependent_hash_conditional_clients_of_firm.size firm.destroy # only the correctly associated client should have been deleted assert_equal 1, Client.where(client_of: firm.id).size @@ -1371,13 +1477,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_creation_respects_hash_condition ms_client = companies(:first_firm).clients_like_ms_with_hash_conditions.build - assert ms_client.save - assert_equal "Microsoft", ms_client.name + assert ms_client.save + assert_equal "Microsoft", ms_client.name another_ms_client = companies(:first_firm).clients_like_ms_with_hash_conditions.create - assert another_ms_client.persisted? - assert_equal "Microsoft", another_ms_client.name + assert_predicate another_ms_client, :persisted? + assert_equal "Microsoft", another_ms_client.name end def test_clearing_without_initial_access @@ -1488,7 +1594,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_predicate companies(:first_firm).clients_of_firm, :loaded? clients = companies(:first_firm).clients_of_firm.to_a - assert !clients.empty?, "37signals has clients after load" + assert_not clients.empty?, "37signals has clients after load" destroyed = companies(:first_firm).clients_of_firm.destroy_all assert_equal clients.sort_by(&:id), destroyed.sort_by(&:id) assert destroyed.all?(&:frozen?), "destroyed clients should be frozen" @@ -1500,7 +1606,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm = companies(:first_firm) assert_equal 3, firm.clients.size firm.destroy - assert Client.all.merge!(where: "firm_id=#{firm.id}").to_a.empty? + assert_empty Client.all.merge!(where: "firm_id=#{firm.id}").to_a end def test_dependence_for_associations_with_hash_condition @@ -1509,8 +1615,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_destroy_dependent_when_deleted_from_association - # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first - firm = Firm.all.merge!(order: "id").first + firm = Firm.first assert_equal 3, firm.clients.size client = firm.clients.first @@ -1564,7 +1669,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm = RestrictedWithExceptionFirm.create!(name: "restrict") firm.companies.create(name: "child") - assert !firm.companies.empty? + assert_not_empty firm.companies assert_raise(ActiveRecord::DeleteRestrictionError) { firm.destroy } assert RestrictedWithExceptionFirm.exists?(name: "restrict") assert firm.companies.exists?(name: "child") @@ -1574,11 +1679,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm = RestrictedWithErrorFirm.create!(name: "restrict") firm.companies.create(name: "child") - assert !firm.companies.empty? + assert_not_empty firm.companies firm.destroy - assert !firm.errors.empty? + assert_not_empty firm.errors assert_equal "Cannot delete record because dependent companies exist", firm.errors[:base].first assert RestrictedWithErrorFirm.exists?(name: "restrict") @@ -1591,11 +1696,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm = RestrictedWithErrorFirm.create!(name: "restrict") firm.companies.create(name: "child") - assert !firm.companies.empty? + assert_not_empty firm.companies firm.destroy - assert !firm.errors.empty? + assert_not_empty firm.errors assert_equal "Cannot delete record because dependent client companies exist", firm.errors[:base].first assert RestrictedWithErrorFirm.exists?(name: "restrict") @@ -1620,7 +1725,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_replace_with_less - firm = Firm.all.merge!(order: "id").first + firm = Firm.first firm.clients = [companies(:first_client)] assert firm.save, "Could not save firm" firm.reload @@ -1634,7 +1739,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_replace_with_new - firm = Firm.all.merge!(order: "id").first + firm = Firm.first firm.clients = [companies(:second_client), Client.new("name" => "New Client")] firm.save firm.reload @@ -1647,8 +1752,8 @@ class HasManyAssociationsTest < ActiveRecord::TestCase account = Account.new orig_accounts = firm.accounts.to_a - assert !account.valid? - assert !orig_accounts.empty? + assert_not_predicate account, :valid? + assert_not_empty orig_accounts error = assert_raise ActiveRecord::RecordNotSaved do firm.accounts = [account] end @@ -1706,9 +1811,9 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_get_ids_for_unloaded_associations_does_not_load_them company = companies(:first_firm) - assert !company.clients.loaded? + assert_not_predicate company.clients, :loaded? assert_equal [companies(:first_client).id, companies(:second_client).id, companies(:another_first_firm_client).id], company.client_ids - assert !company.clients.loaded? + assert_not_predicate company.clients, :loaded? end def test_counter_cache_on_unloaded_association @@ -1773,6 +1878,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase ].each { |block| assert_raise(ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection, &block) } end + def test_associations_order_should_be_priority_over_throughs_order + david = authors(:david) + expected = [12, 10, 9, 8, 7, 6, 5, 3, 2, 1] + assert_equal expected, david.comments_desc.map(&:id) + assert_equal expected, Author.includes(:comments_desc).find(david.id).comments_desc.map(&:id) + end + def test_dynamic_find_should_respect_association_order_for_through assert_equal Comment.find(10), authors(:david).comments_desc.where("comments.type = 'SpecialComment'").first assert_equal Comment.find(10), authors(:david).comments_desc.find_by_type("SpecialComment") @@ -1790,7 +1902,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase client = firm.clients.first assert_no_queries do - assert firm.clients.loaded? + assert_predicate firm.clients, :loaded? assert_equal true, firm.clients.include?(client) end end @@ -1800,18 +1912,18 @@ class HasManyAssociationsTest < ActiveRecord::TestCase client = firm.clients.first firm.reload - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_queries(1) do assert_equal true, firm.clients.include?(client) end - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_include_returns_false_for_non_matching_record_to_verify_scoping firm = companies(:first_firm) client = Client.create!(name: "Not Associated") - assert ! firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_equal false, firm.clients.include?(client) end @@ -1820,13 +1932,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm.clients.first firm.clients.second firm.clients.last - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_calling_first_or_last_on_loaded_association_should_not_fetch_with_query firm = companies(:first_firm) firm.clients.load_target - assert firm.clients.loaded? + assert_predicate firm.clients, :loaded? assert_no_queries(ignore_none: false) do firm.clients.first @@ -1839,7 +1951,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_calling_first_or_last_on_existing_record_with_build_should_load_association firm = companies(:first_firm) firm.clients.build(name: "Foo") - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_queries 1 do firm.clients.first @@ -1847,13 +1959,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm.clients.last end - assert firm.clients.loaded? + assert_predicate firm.clients, :loaded? end def test_calling_first_nth_or_last_on_existing_record_with_create_should_not_load_association firm = companies(:first_firm) firm.clients.create(name: "Foo") - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_queries 3 do firm.clients.first @@ -1861,7 +1973,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm.clients.last end - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_calling_first_nth_or_last_on_new_record_should_not_run_queries @@ -1877,14 +1989,14 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_calling_first_or_last_with_integer_on_association_should_not_load_association firm = companies(:first_firm) firm.clients.create(name: "Foo") - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? assert_queries 2 do firm.clients.first(2) firm.clients.last(2) end - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_calling_many_should_count_instead_of_loading_association @@ -1892,37 +2004,38 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_queries(1) do firm.clients.many? # use count query end - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_calling_many_on_loaded_association_should_not_use_query firm = companies(:first_firm) - firm.clients.collect # force load + firm.clients.load # force load assert_no_queries { assert firm.clients.many? } end def test_calling_many_should_defer_to_collection_if_using_a_block firm = companies(:first_firm) assert_queries(1) do - firm.clients.expects(:size).never - firm.clients.many? { true } + assert_not_called(firm.clients, :size) do + firm.clients.many? { true } + end end - assert firm.clients.loaded? + assert_predicate firm.clients, :loaded? end def test_calling_many_should_return_false_if_none_or_one firm = companies(:another_firm) - assert !firm.clients_like_ms.many? + assert_not_predicate firm.clients_like_ms, :many? assert_equal 0, firm.clients_like_ms.size firm = companies(:first_firm) - assert !firm.limited_clients.many? + assert_not_predicate firm.limited_clients, :many? assert_equal 1, firm.limited_clients.size end def test_calling_many_should_return_true_if_more_than_one firm = companies(:first_firm) - assert firm.clients.many? + assert_predicate firm.clients, :many? assert_equal 3, firm.clients.size end @@ -1931,33 +2044,34 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_queries(1) do firm.clients.none? # use count query end - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_calling_none_on_loaded_association_should_not_use_query firm = companies(:first_firm) - firm.clients.collect # force load - assert_no_queries { assert ! firm.clients.none? } + firm.clients.load # force load + assert_no_queries { assert_not firm.clients.none? } end def test_calling_none_should_defer_to_collection_if_using_a_block firm = companies(:first_firm) assert_queries(1) do - firm.clients.expects(:size).never - firm.clients.none? { true } + assert_not_called(firm.clients, :size) do + firm.clients.none? { true } + end end - assert firm.clients.loaded? + assert_predicate firm.clients, :loaded? end def test_calling_none_should_return_true_if_none firm = companies(:another_firm) - assert firm.clients_like_ms.none? + assert_predicate firm.clients_like_ms, :none? assert_equal 0, firm.clients_like_ms.size end def test_calling_none_should_return_false_if_any firm = companies(:first_firm) - assert !firm.limited_clients.none? + assert_not_predicate firm.limited_clients, :none? assert_equal 1, firm.limited_clients.size end @@ -1966,39 +2080,40 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_queries(1) do firm.clients.one? # use count query end - assert !firm.clients.loaded? + assert_not_predicate firm.clients, :loaded? end def test_calling_one_on_loaded_association_should_not_use_query firm = companies(:first_firm) - firm.clients.collect # force load - assert_no_queries { assert ! firm.clients.one? } + firm.clients.load # force load + assert_no_queries { assert_not firm.clients.one? } end def test_calling_one_should_defer_to_collection_if_using_a_block firm = companies(:first_firm) assert_queries(1) do - firm.clients.expects(:size).never - firm.clients.one? { true } + assert_not_called(firm.clients, :size) do + firm.clients.one? { true } + end end - assert firm.clients.loaded? + assert_predicate firm.clients, :loaded? end def test_calling_one_should_return_false_if_zero firm = companies(:another_firm) - assert ! firm.clients_like_ms.one? + assert_not_predicate firm.clients_like_ms, :one? assert_equal 0, firm.clients_like_ms.size end def test_calling_one_should_return_true_if_one firm = companies(:first_firm) - assert firm.limited_clients.one? + assert_predicate firm.limited_clients, :one? assert_equal 1, firm.limited_clients.size end def test_calling_one_should_return_false_if_more_than_one firm = companies(:first_firm) - assert ! firm.clients.one? + assert_not_predicate firm.clients, :one? assert_equal 3, firm.clients.size end @@ -2020,9 +2135,10 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_association_proxy_transaction_method_starts_transaction_in_association_class - Comment.expects(:transaction) - Post.first.comments.transaction do - # nothing + assert_called(Comment, :transaction) do + Post.first.comments.transaction do + # nothing + end end end @@ -2031,34 +2147,36 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal client_association.new.attributes, client_association.send(:new).attributes end - def test_respond_to_private_class_methods - client_association = companies(:first_firm).clients - assert !client_association.respond_to?(:private_method) - assert client_association.respond_to?(:private_method, true) - end - def test_creating_using_primary_key - firm = Firm.all.merge!(order: "id").first + firm = Firm.first client = firm.clients_using_primary_key.create!(name: "test") assert_equal firm.name, client.firm_name end def test_defining_has_many_association_with_delete_all_dependency_lazily_evaluates_target_class - ActiveRecord::Reflection::AssociationReflection.any_instance.expects(:class_name).never - class_eval(<<-EOF, __FILE__, __LINE__ + 1) - class DeleteAllModel < ActiveRecord::Base - has_many :nonentities, :dependent => :delete_all - end - EOF + assert_not_called_on_instance_of( + ActiveRecord::Reflection::AssociationReflection, + :class_name, + ) do + class_eval(<<-EOF, __FILE__, __LINE__ + 1) + class DeleteAllModel < ActiveRecord::Base + has_many :nonentities, :dependent => :delete_all + end + EOF + end end def test_defining_has_many_association_with_nullify_dependency_lazily_evaluates_target_class - ActiveRecord::Reflection::AssociationReflection.any_instance.expects(:class_name).never - class_eval(<<-EOF, __FILE__, __LINE__ + 1) - class NullifyModel < ActiveRecord::Base - has_many :nonentities, :dependent => :nullify - end - EOF + assert_not_called_on_instance_of( + ActiveRecord::Reflection::AssociationReflection, + :class_name, + ) do + class_eval(<<-EOF, __FILE__, __LINE__ + 1) + class NullifyModel < ActiveRecord::Base + has_many :nonentities, :dependent => :nullify + end + EOF + end end def test_attributes_are_being_set_when_initialized_from_has_many_association_with_where_clause @@ -2140,6 +2258,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal "Post", tagging.taggable_type end + def test_build_from_polymorphic_association_sets_inverse_instance + post = Post.new + tagging = post.taggings.build + + assert_equal post, tagging.taggable + end + def test_dont_call_save_callbacks_twice_on_has_many firm = companies(:first_firm) contract = firm.contracts.create! @@ -2217,7 +2342,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase post = posts(:welcome) assert post.taggings_with_delete_all.count > 0 - assert !post.taggings_with_delete_all.loaded? + assert_not_predicate post.taggings_with_delete_all, :loaded? # 2 queries: one DELETE and another to update the counter cache assert_queries(2) do @@ -2239,7 +2364,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase test "collection proxy respects default scope" do author = authors(:mary) - assert !author.first_posts.exists? + assert_not_predicate author.first_posts, :exists? end test "association with extend option" do @@ -2251,7 +2376,15 @@ class HasManyAssociationsTest < ActiveRecord::TestCase test "association with extend option with multiple extensions" do post = posts(:welcome) assert_equal "lifo", post.comments_with_extend_2.author - assert_equal "hello", post.comments_with_extend_2.greeting + 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 end test "delete record with complex joins" do @@ -2272,7 +2405,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase test "does not duplicate associations when used with natural primary keys" do speedometer = Speedometer.create!(id: "4") - speedometer.minivans.create!(minivan_id: "a-van-red" , name: "a van", color: "red") + speedometer.minivans.create!(minivan_id: "a-van-red", name: "a van", color: "red") assert_equal 1, speedometer.minivans.to_a.size, "Only one association should be present:\n#{speedometer.minivans.to_a}" assert_equal 1, speedometer.reload.minivans.to_a.size @@ -2311,8 +2444,9 @@ class HasManyAssociationsTest < ActiveRecord::TestCase car = Car.create! bulb = Bulb.create! name: "other", car: car - assert_equal bulb, Car.find(car.id).all_bulbs.first - assert_equal bulb, Car.includes(:all_bulbs).find(car.id).all_bulbs.first + assert_equal [bulb], Car.find(car.id).all_bulbs + assert_equal [bulb], Car.includes(:all_bulbs).find(car.id).all_bulbs + assert_equal [bulb], Car.eager_load(:all_bulbs).find(car.id).all_bulbs end test "raises RecordNotDestroyed when replaced child can't be destroyed" do @@ -2349,7 +2483,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase pirate = FamousPirate.new pirate.famous_ships << ship = FamousShip.new - assert pirate.valid? + assert_predicate pirate, :valid? assert_not pirate.valid?(:conference) assert_equal "can't be blank", ship.errors[:name].first end @@ -2430,6 +2564,15 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_same car, new_bulb.car end + test "reattach to new objects replaces inverse association and foreign key" do + bulb = Bulb.create!(car: Car.create!) + assert bulb.car_id + car = Car.new + car.bulbs << bulb + assert_equal car, bulb.car + assert_nil bulb.car_id + end + test "in memory replacement maintains order" do first_bulb = Bulb.create! second_bulb = Bulb.create! @@ -2441,15 +2584,101 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal [first_bulb, second_bulb], car.bulbs end - test "double insertion of new object to association when same association used in the after create callback of a new object" do + test "association size calculation works with default scoped selects when not previously fetched" do + firm = Firm.create!(name: "Firm") + 5.times { firm.developers_with_select << Developer.create!(name: "Developer") } + + same_firm = Firm.find(firm.id) + assert_equal 5, same_firm.developers_with_select.size + end + + test "prevent double insertion of new object when the parent association loaded in the after save callback" do reset_callbacks(:save, Bulb) do Bulb.after_save { |record| record.car.bulbs.load } + car = Car.create! car.bulbs << Bulb.new + assert_equal 1, car.bulbs.size end end + test "prevent double firing the before save callback of new object when the parent association saved in the callback" do + reset_callbacks(:save, Bulb) do + count = 0 + Bulb.before_save { |record| record.car.save && count += 1 } + + car = Car.create! + car.bulbs.create! + + assert_equal 1, count + end + end + + test "calling size on an association that has not been loaded performs a query" do + car = Car.create! + Bulb.create(car_id: car.id) + + car_two = Car.create! + + assert_queries(1) do + assert_equal 1, car.bulbs.size + end + + assert_queries(1) do + assert_equal 0, car_two.bulbs.size + end + end + + test "calling size on an association that has been loaded does not perform query" do + car = Car.create! + Bulb.create(car_id: car.id) + car.bulb_ids + + car_two = Car.create! + car_two.bulb_ids + + assert_no_queries do + assert_equal 1, car.bulbs.size + end + + assert_no_queries do + assert_equal 0, car_two.bulbs.size + end + end + + test "calling empty on an association that has not been loaded performs a query" do + car = Car.create! + Bulb.create(car_id: car.id) + + car_two = Car.create! + + assert_queries(1) do + assert_not_empty car.bulbs + end + + assert_queries(1) do + assert_empty car_two.bulbs + end + end + + test "calling empty on an association that has been loaded does not performs query" do + car = Car.create! + Bulb.create(car_id: car.id) + car.bulb_ids + + car_two = Car.create! + car_two.bulb_ids + + assert_no_queries do + assert_not_empty car.bulbs + end + + assert_no_queries do + assert_empty car_two.bulbs + end + end + class AuthorWithErrorDestroyingAssociation < ActiveRecord::Base self.table_name = "authors" has_many :posts_with_error_destroying, @@ -2486,11 +2715,16 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal [bulb.id], car.bulb_ids assert_no_queries { car.bulb_ids } + + bulb2 = car.bulbs.create! + + assert_equal [bulb.id, bulb2.id], car.bulb_ids + assert_no_queries { car.bulb_ids } end def test_loading_association_in_validate_callback_doesnt_affect_persistence reset_callbacks(:validation, Bulb) do - Bulb.after_validation { |m| m.car.bulbs.load } + Bulb.after_validation { |record| record.car.bulbs.load } car = Car.create!(name: "Car") bulb = car.bulbs.create! @@ -2499,6 +2733,17 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end end + def test_create_children_could_be_rolled_back_by_after_save + firm = Firm.create!(name: "A New Firm, Inc") + assert_no_difference "Client.count" do + client = firm.clients.create(name: "New Client") do |cli| + cli.rollback_on_save = true + assert_not cli.rollback_on_create_called + end + assert client.rollback_on_create_called + end + end + private def force_signal37_to_load_all_clients_of_firm 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 ea52fb5a67..d5573b6d02 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/person" @@ -44,6 +46,11 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase Reader.create person_id: 0, post_id: 0 end + def test_marshal_dump + preloaded = Post.includes(:first_blue_tags).first + assert_equal preloaded, Marshal.load(Marshal.dump(preloaded)) + end + def test_preload_sti_rhs_class developers = Developer.includes(:firms).all.to_a assert_no_queries do @@ -64,10 +71,6 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase club1.members.sort_by(&:id) end - def make_model(name) - Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } - end - def test_ordered_has_many_through person_prime = Class.new(ActiveRecord::Base) do def self.name; "Person"; end @@ -152,20 +155,6 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert after_destroy_called, "after destroy should be called" end - def make_no_pk_hm_t - lesson = make_model "Lesson" - student = make_model "Student" - - lesson_student = make_model "LessonStudent" - lesson_student.table_name = "lessons_students" - - lesson_student.belongs_to :lesson, anonymous_class: lesson - lesson_student.belongs_to :student, anonymous_class: student - lesson.has_many :lesson_students, anonymous_class: lesson_student - lesson.has_many :students, through: :lesson_students, anonymous_class: student - [lesson, lesson_student, student] - end - def test_pk_is_not_required_for_join post = Post.includes(:scategories).first post2 = Post.includes(:categories).first @@ -337,6 +326,17 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_includes post.single_people, person end + def test_build_then_remove_then_save + post = posts(:thinking) + post.people.build(first_name: "Bob") + ted = post.people.build(first_name: "Ted") + post.people.delete(ted) + post.save! + post.reload + + assert_equal ["Bob"], post.people.collect(&:first_name) + end + def test_both_parent_ids_set_when_saving_new post = Post.new(title: "Hello", body: "world") person = Person.new(first_name: "Sean") @@ -358,10 +358,10 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end assert_queries(1) do - assert posts(:welcome).people.empty? + assert_empty posts(:welcome).people end - assert posts(:welcome).reload.people.reload.empty? + assert_empty posts(:welcome).reload.people.reload end def test_destroy_association @@ -371,8 +371,8 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end end - assert posts(:welcome).reload.people.empty? - assert posts(:welcome).people.reload.empty? + assert_empty posts(:welcome).reload.people + assert_empty posts(:welcome).people.reload end def test_destroy_all @@ -382,8 +382,8 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end end - assert posts(:welcome).reload.people.empty? - assert posts(:welcome).people.reload.empty? + assert_empty posts(:welcome).reload.people + assert_empty posts(:welcome).people.reload end def test_should_raise_exception_for_destroying_mismatching_records @@ -543,6 +543,16 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end end + def test_update_counter_caches_on_destroy_with_indestructible_through_record + post = posts(:welcome) + tag = post.indestructible_tags.create!(name: "doomed") + post.update_columns(indestructible_tags_count: post.indestructible_tags.count) + + assert_no_difference "post.reload.indestructible_tags_count" do + posts(:welcome).indestructible_tags.destroy(tag) + end + end + def test_replace_association assert_queries(4) { posts(:welcome);people(:david);people(:michael); posts(:welcome).people.reload } @@ -680,10 +690,10 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end assert_queries(0) do - assert posts(:welcome).people.empty? + assert_empty posts(:welcome).people end - assert posts(:welcome).reload.people.reload.empty? + assert_empty posts(:welcome).reload.people.reload end def test_association_callback_ordering @@ -727,6 +737,18 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase [:added, :before, "Roger"], [:added, :after, "Roger"] ], log.last(4) + + post.people_with_callbacks.build { |person| person.first_name = "Ted" } + assert_equal [ + [:added, :before, "Ted"], + [:added, :after, "Ted"] + ], log.last(2) + + post.people_with_callbacks.create { |person| person.first_name = "Sam" } + assert_equal [ + [:added, :before, "Sam"], + [:added, :after, "Sam"] + ], log.last(2) end def test_dynamic_find_should_respect_association_include @@ -765,9 +787,9 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase def test_get_ids_for_unloaded_associations_does_not_load_them person = people(:michael) - assert !person.posts.loaded? + assert_not_predicate person.posts, :loaded? assert_equal [posts(:welcome).id, posts(:authorless).id].sort, person.post_ids.sort - assert !person.posts.loaded? + assert_not_predicate person.posts, :loaded? end def test_association_proxy_transaction_method_starts_transaction_in_association_class @@ -822,7 +844,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase sarah = Person.create!(first_name: "Sarah", primary_contact_id: people(:susan).id, gender: "F", number1_fan_id: 1) john = Person.create!(first_name: "John", primary_contact_id: sarah.id, gender: "M", number1_fan_id: 1) assert_equal sarah.agents, [john] - assert_equal people(:susan).agents.flat_map(&:agents), people(:susan).agents_of_agents + assert_equal people(:susan).agents.flat_map(&:agents).sort, people(:susan).agents_of_agents.sort end def test_associate_existing_with_nonstandard_primary_key_on_belongs_to @@ -856,8 +878,8 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase author = authors(:mary) category = author.named_categories.create(name: "Primary") author.named_categories.delete(category) - assert !Categorization.exists?(author_id: author.id, named_category_name: category.name) - assert author.named_categories.reload.empty? + assert_not Categorization.exists?(author_id: author.id, named_category_name: category.name) + assert_empty author.named_categories.reload end def test_collection_singular_ids_getter_with_string_primary_keys @@ -874,6 +896,14 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [dev], company.developers end + def test_collection_singular_ids_setter_with_required_type_cast + company = companies(:rails_core) + dev = Developer.first + + company.developer_ids = [dev.id.to_s] + assert_equal [dev], company.developers + end + def test_collection_singular_ids_setter_with_string_primary_keys assert_nothing_raised do book = books(:awdr) @@ -885,32 +915,20 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end end - def test_collection_singular_ids_setter_with_changed_primary_key - company = companies(:first_firm) - client = companies(:first_client) - company.clients_using_primary_key_ids = [client.name] - assert_equal [client], company.clients_using_primary_key - end - def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set company = companies(:rails_core) ids = [Developer.first.id, -9999] e = assert_raises(ActiveRecord::RecordNotFound) { company.developer_ids = ids } - assert_match(/Couldn't find all Developers with 'id'/, e.message) - end - - def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set_with_changed_primary_key - company = companies(:first_firm) - ids = [Client.first.name, "unknown client"] - e = assert_raises(ActiveRecord::RecordNotFound) { company.clients_using_primary_key_ids = ids } - assert_match(/Couldn't find all Clients with 'name'/, e.message) + msg = "Couldn't find all Developers with 'id': (1, -9999) (found 1 results, but was looking for 2). Couldn't find Developer with id -9999." + assert_equal(msg, e.message) end def test_collection_singular_ids_through_setter_raises_exception_when_invalid_ids_set author = authors(:david) ids = [categories(:general).name, "Unknown"] e = assert_raises(ActiveRecord::RecordNotFound) { author.essay_category_ids = ids } - assert_equal "Couldn't find all Categories with 'name': (General, Unknown) (found 1 results, but was looking for 2)", e.message + msg = "Couldn't find all Categories with 'name': (General, Unknown) (found 1 results, but was looking for 2). Couldn't find Category with name Unknown." + assert_equal msg, e.message end def test_build_a_model_from_hm_through_association_with_where_clause @@ -943,8 +961,8 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_through_association_readonly_should_be_false - assert !people(:michael).posts.first.readonly? - assert !people(:michael).posts.to_a.first.readonly? + assert_not_predicate people(:michael).posts.first, :readonly? + assert_not_predicate people(:michael).posts.to_a.first, :readonly? end def test_can_update_through_association @@ -953,6 +971,13 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end end + def test_has_many_through_polymorphic_with_rewhere + post = TaggedPost.create!(title: "Tagged", body: "Post") + tag = post.tags.create!(name: "Tag") + assert_equal [tag], TaggedPost.preload(:tags).last.tags + assert_equal [tag], TaggedPost.eager_load(:tags).last.tags + end + def test_has_many_through_polymorphic_with_primary_key_option assert_equal [categories(:general)], authors(:david).essay_categories @@ -1026,12 +1051,12 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase post.author_categorizations proxy = post.send(:association_instance_get, :author_categorizations) - assert !proxy.stale_target? + assert_not_predicate proxy, :stale_target? assert_equal authors(:mary).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id) post.author_id = authors(:david).id - assert proxy.stale_target? + assert_predicate proxy, :stale_target? assert_equal authors(:david).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id) end @@ -1048,7 +1073,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_includes post.author_addresses, address post.author_addresses.delete(address) - assert post[:author_count].nil? + assert_predicate post[:author_count], :nil? end def test_primary_key_option_on_source @@ -1135,6 +1160,32 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal ["parrot", "bulbul"], owner.toys.map { |r| r.pet.name } end + def test_has_many_through_associations_sum_on_columns + post1 = Post.create(title: "active", body: "sample") + post2 = Post.create(title: "inactive", body: "sample") + + person1 = Person.create(first_name: "aaron", followers_count: 1) + person2 = Person.create(first_name: "schmit", followers_count: 2) + person3 = Person.create(first_name: "bill", followers_count: 3) + person4 = Person.create(first_name: "cal", followers_count: 4) + + Reader.create(post_id: post1.id, person_id: person1.id) + Reader.create(post_id: post1.id, person_id: person2.id) + Reader.create(post_id: post1.id, person_id: person3.id) + Reader.create(post_id: post1.id, person_id: person4.id) + + Reader.create(post_id: post2.id, person_id: person1.id) + Reader.create(post_id: post2.id, person_id: person2.id) + Reader.create(post_id: post2.id, person_id: person3.id) + Reader.create(post_id: post2.id, person_id: person4.id) + + active_persons = Person.joins(:readers).joins(:posts).distinct(true).where("posts.title" => "active") + + assert_equal active_persons.map(&:followers_count).reduce(:+), 10 + assert_equal active_persons.sum(:followers_count), 10 + assert_equal active_persons.sum(:followers_count), active_persons.map(&:followers_count).reduce(:+) + end + def test_has_many_through_associations_on_new_records_use_null_relations person = Person.new @@ -1234,6 +1285,22 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase TenantMembership.current_member = nil end + def test_has_many_through_with_scope_that_has_joined_same_table_with_parent_relation + assert_equal authors(:david), Author.joins(:comments_for_first_author).take + end + + def test_has_many_through_with_left_joined_same_table_with_through_table + assert_equal [comments(:eager_other_comment1)], authors(:mary).comments.left_joins(:post) + end + + def test_has_many_through_with_unscope_should_affect_to_through_scope + assert_equal [comments(:eager_other_comment1)], authors(:mary).unordered_comments + end + + def test_has_many_through_with_scope_should_accept_string_and_hash_join + assert_equal authors(:david), Author.joins({ comments_for_first_author: :post }, "inner join posts posts_alias on authors.id = posts_alias.author_id").eager_load(:categories).take + end + def test_has_many_through_with_scope_should_respect_table_alias family = Family.create! users = 3.times.map { User.create! } @@ -1245,6 +1312,25 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal 0, users[2].family_members.to_a.size end + def test_through_scope_is_affected_by_unscoping + author = authors(:david) + + expected = author.comments.to_a + FirstPost.unscoped do + assert_equal expected.sort_by(&:id), author.comments_on_first_posts.sort_by(&:id) + end + end + + def test_through_scope_isnt_affected_by_scoping + author = authors(:david) + + expected = author.comments_on_first_posts.to_a + FirstPost.where(id: 2).scoping do + author.comments_on_first_posts.reset + assert_equal expected.sort_by(&:id), author.comments_on_first_posts.sort_by(&:id) + end + end + def test_incorrectly_ordered_through_associations assert_raises(ActiveRecord::HasManyThroughOrderError) do DeveloperWithIncorrectlyOrderedHasManyThrough.create( @@ -1252,4 +1338,87 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase ) end end + + def test_has_many_through_update_ids_with_conditions + author = Author.create!(name: "Bill") + category = categories(:general) + + author.update( + special_categories_with_condition_ids: [category.id], + nonspecial_categories_with_condition_ids: [category.id] + ) + + assert_equal [category.id], author.special_categories_with_condition_ids + assert_equal [category.id], author.nonspecial_categories_with_condition_ids + + author.update(nonspecial_categories_with_condition_ids: []) + author.reload + + assert_equal [category.id], author.special_categories_with_condition_ids + assert_equal [], author.nonspecial_categories_with_condition_ids + end + + def test_single_has_many_through_association_with_unpersisted_parent_instance + post_with_single_has_many_through = Class.new(Post) do + def self.name; "PostWithSingleHasManyThrough"; end + has_many :subscriptions, through: :author + end + post = post_with_single_has_many_through.new + + post.author = authors(:mary) + book1 = Book.create!(name: "essays on single has many through associations 1") + post.author.books << book1 + subscription1 = Subscription.first + book1.subscriptions << subscription1 + assert_equal [subscription1], post.subscriptions.to_a + + post.author = authors(:bob) + book2 = Book.create!(name: "essays on single has many through associations 2") + post.author.books << book2 + subscription2 = Subscription.second + book2.subscriptions << subscription2 + assert_equal [subscription2], post.subscriptions.to_a + end + + def test_nested_has_many_through_association_with_unpersisted_parent_instance + post_with_nested_has_many_through = Class.new(Post) do + def self.name; "PostWithNestedHasManyThrough"; end + has_many :books, through: :author + has_many :subscriptions, through: :books + end + post = post_with_nested_has_many_through.new + + post.author = authors(:mary) + book1 = Book.create!(name: "essays on nested has many through associations 1") + post.author.books << book1 + subscription1 = Subscription.first + book1.subscriptions << subscription1 + assert_equal [subscription1], post.subscriptions.to_a + + post.author = authors(:bob) + book2 = Book.create!(name: "essays on nested has many through associations 2") + post.author.books << book2 + subscription2 = Subscription.second + book2.subscriptions << subscription2 + assert_equal [subscription2], post.subscriptions.to_a + end + + private + def make_model(name) + Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } + end + + def make_no_pk_hm_t + lesson = make_model "Lesson" + student = make_model "Student" + + lesson_student = make_model "LessonStudent" + lesson_student.table_name = "lessons_students" + + lesson_student.belongs_to :lesson, anonymous_class: lesson + lesson_student.belongs_to :student, anonymous_class: student + lesson.has_many :lesson_students, anonymous_class: lesson_student + lesson.has_many :students, through: :lesson_students, anonymous_class: student + [lesson, lesson_student, student] + end end diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb index 7c11d2e7fc..9eea34d2b9 100644 --- a/activerecord/test/cases/associations/has_one_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/developer" require "models/computer" @@ -13,7 +15,7 @@ require "models/post" class HasOneAssociationsTest < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? - fixtures :accounts, :companies, :developers, :projects, :developers_projects, :ships, :pirates + fixtures :accounts, :companies, :developers, :projects, :developers_projects, :ships, :pirates, :authors, :author_addresses def setup Account.destroyed_account_ids.clear @@ -28,7 +30,8 @@ class HasOneAssociationsTest < ActiveRecord::TestCase ActiveRecord::SQLCounter.clear_log companies(:first_firm).account ensure - assert ActiveRecord::SQLCounter.log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query" + log_all = ActiveRecord::SQLCounter.log_all + assert log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query: #{log_all}" end def test_has_one_cache_nils @@ -111,8 +114,8 @@ class HasOneAssociationsTest < ActiveRecord::TestCase developer = Developer.create!(name: "Someone") ship = Ship.create!(name: "Planet Caravan", developer: developer) ship.destroy - assert !ship.persisted? - assert !developer.persisted? + assert_not_predicate ship, :persisted? + assert_not_predicate developer, :persisted? end def test_natural_assignment_to_nil_after_destroy @@ -183,7 +186,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase assert_raise(ActiveRecord::DeleteRestrictionError) { firm.destroy } assert RestrictedWithExceptionFirm.exists?(name: "restrict") - assert firm.account.present? + assert_predicate firm.account, :present? end def test_restrict_with_error @@ -194,10 +197,10 @@ class HasOneAssociationsTest < ActiveRecord::TestCase firm.destroy - assert !firm.errors.empty? + assert_not_empty firm.errors assert_equal "Cannot delete record because a dependent account exists", firm.errors[:base].first assert RestrictedWithErrorFirm.exists?(name: "restrict") - assert firm.account.present? + assert_predicate firm.account, :present? end def test_restrict_with_error_with_locale @@ -210,10 +213,10 @@ class HasOneAssociationsTest < ActiveRecord::TestCase firm.destroy - assert !firm.errors.empty? + assert_not_empty firm.errors assert_equal "Cannot delete record because a dependent firm account exists", firm.errors[:base].first assert RestrictedWithErrorFirm.exists?(name: "restrict") - assert firm.account.present? + assert_predicate firm.account, :present? ensure I18n.backend.reload! end @@ -307,6 +310,15 @@ class HasOneAssociationsTest < ActiveRecord::TestCase end end + def test_create_when_parent_is_new_raises + firm = Firm.new + error = assert_raise(ActiveRecord::RecordNotSaved) do + firm.create_account + end + + assert_equal "You cannot call create unless the parent is saved", error.message + end + def test_reload_association odegy = companies(:odegy) @@ -365,7 +377,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase def test_assignment_before_child_saved firm = Firm.find(1) firm.account = a = Account.new("credit_limit" => 1000) - assert a.persisted? + assert_predicate a, :persisted? assert_equal a, firm.account assert_equal a, firm.account firm.association(:account).reload @@ -383,7 +395,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase def test_cant_save_readonly_association assert_raise(ActiveRecord::ReadOnlyRecord) { companies(:first_firm).readonly_account.save! } - assert companies(:first_firm).readonly_account.readonly? + assert_predicate companies(:first_firm).readonly_account, :readonly? end def test_has_one_proxy_should_not_respond_to_private_methods @@ -421,7 +433,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase def test_create_respects_hash_condition account = companies(:first_firm).create_account_limit_500_with_hash_conditions - assert account.persisted? + assert_predicate account, :persisted? assert_equal 500, account.credit_limit end @@ -438,9 +450,9 @@ class HasOneAssociationsTest < ActiveRecord::TestCase new_ship = pirate.create_ship assert_not_equal ships(:black_pearl), new_ship assert_equal new_ship, pirate.ship - assert new_ship.new_record? + assert_predicate new_ship, :new_record? assert_nil orig_ship.pirate_id - assert !orig_ship.changed? # check it was saved + assert_not orig_ship.changed? # check it was saved end def test_creation_failure_with_dependent_option @@ -448,8 +460,8 @@ class HasOneAssociationsTest < ActiveRecord::TestCase orig_ship = pirate.dependent_ship new_ship = pirate.create_dependent_ship - assert new_ship.new_record? - assert orig_ship.destroyed? + assert_predicate new_ship, :new_record? + assert_predicate orig_ship, :destroyed? end def test_creation_failure_due_to_new_record_should_raise_error @@ -469,7 +481,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase pirate = pirates(:blackbeard) pirate.ship.name = nil - assert !pirate.ship.valid? + assert_not_predicate pirate.ship, :valid? error = assert_raise(ActiveRecord::RecordNotSaved) do pirate.ship = ships(:interceptor) end @@ -576,7 +588,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase ship.save! ship.name = "new name" - assert ship.changed? + assert_predicate ship, :changed? assert_queries(1) do # One query for updating name, not triggering query for updating pirate_id pirate.ship = ship @@ -649,6 +661,8 @@ class HasOneAssociationsTest < ActiveRecord::TestCase self.table_name = "books" belongs_to :author, class_name: "SpecialAuthor" has_one :subscription, class_name: "SpecialSupscription", foreign_key: "subscriber_id" + + enum status: [:proposed, :written, :published] end class SpecialAuthor < ActiveRecord::Base @@ -666,7 +680,8 @@ class HasOneAssociationsTest < ActiveRecord::TestCase book = SpecialBook.create!(status: "published") author.book = book - refute_equal 0, SpecialAuthor.joins(:book).where(books: { status: "published" }).count + assert_equal "published", book.status + assert_not_equal 0, SpecialAuthor.joins(:book).where(books: { status: "published" }).count end def test_association_enum_works_properly_with_nested_join @@ -679,4 +694,62 @@ class HasOneAssociationsTest < ActiveRecord::TestCase SpecialAuthor.joins(book: :subscription).where.not(where_clause) end end + + class DestroyByParentBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "DestroyByParentAuthor" + before_destroy :dont, unless: :destroyed_by_association + + def dont + throw(:abort) + end + end + + class DestroyByParentAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "DestroyByParentBook", foreign_key: "author_id", dependent: :destroy + end + + test "destroyed_by_association set in child destroy callback on parent destroy" do + author = DestroyByParentAuthor.create!(name: "Test") + book = DestroyByParentBook.create!(author: author) + + author.destroy + + assert_not DestroyByParentBook.exists?(book.id) + end + + test "destroyed_by_association set in child destroy callback on replace" do + author = DestroyByParentAuthor.create!(name: "Test") + book = DestroyByParentBook.create!(author: author) + + author.book = DestroyByParentBook.create! + author.save! + + assert_not DestroyByParentBook.exists?(book.id) + end + + class UndestroyableBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "DestroyableAuthor" + before_destroy :dont + + def dont + throw(:abort) + end + end + + class DestroyableAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "UndestroyableBook", foreign_key: "author_id", dependent: :destroy + end + + def test_dependency_should_halt_parent_destruction + author = DestroyableAuthor.create!(name: "Test") + UndestroyableBook.create!(author: author) + + assert_no_difference ["DestroyableAuthor.count", "UndestroyableBook.count"] do + assert_not author.destroy + end + end end diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb index 38a729d2d4..0309663943 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/club" require "models/member_type" @@ -23,7 +25,7 @@ require "models/customer_carrier" class HasOneThroughAssociationsTest < ActiveRecord::TestCase fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, - :dashboards, :speedometers, :authors, :posts, :comments, :categories, :essays, :owners + :dashboards, :speedometers, :authors, :author_addresses, :posts, :comments, :categories, :essays, :owners def setup @member = members(:groucho) @@ -40,6 +42,18 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase assert_not_nil new_member.club end + def test_creating_association_builds_through_record + new_member = Member.create(name: "Chris") + new_club = new_member.association(:club).build + assert new_member.current_membership + assert_equal new_club, new_member.club + assert_predicate new_club, :new_record? + assert_predicate new_member.current_membership, :new_record? + assert new_member.save + assert_predicate new_club, :persisted? + assert_predicate new_member.current_membership, :persisted? + end + def test_creating_association_builds_through_record_for_new new_member = Member.new(name: "Jane") new_member.club = clubs(:moustache_club) @@ -50,6 +64,24 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase assert_equal clubs(:moustache_club), new_member.club end + def test_building_multiple_associations_builds_through_record + member_type = MemberType.create! + member = Member.create! + member_detail_with_one_association = MemberDetail.new(member_type: member_type) + assert_predicate member_detail_with_one_association.member, :new_record? + member_detail_with_two_associations = MemberDetail.new(member_type: member_type, admittable: member) + assert_predicate member_detail_with_two_associations.member, :new_record? + end + + def test_creating_multiple_associations_creates_through_record + member_type = MemberType.create! + member = Member.create! + member_detail_with_one_association = MemberDetail.create!(member_type: member_type) + assert_not_predicate member_detail_with_one_association.member, :new_record? + member_detail_with_two_associations = MemberDetail.create!(member_type: member_type, admittable: member) + assert_not_predicate member_detail_with_two_associations.member, :new_record? + end + def test_creating_association_sets_both_parent_ids_for_new member = Member.new(name: "Sean Griffin") club = Club.new(name: "Da Club") @@ -98,7 +130,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase end def test_has_one_through_eager_loading - members = assert_queries(3) do #base table, through table, clubs table + members = assert_queries(3) do # base table, through table, clubs table Member.all.merge!(includes: :club, where: ["name = ?", "Groucho Marx"]).to_a end assert_equal 1, members.size @@ -106,7 +138,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase end def test_has_one_through_eager_loading_through_polymorphic - members = assert_queries(3) do #base table, through table, clubs table + members = assert_queries(3) do # base table, through table, clubs table Member.all.merge!(includes: :sponsor_club, where: ["name = ?", "Groucho Marx"]).to_a end assert_equal 1, members.size @@ -137,7 +169,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase def test_has_one_through_nonpreload_eagerloading members = assert_queries(1) do - Member.all.merge!(includes: :club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name").to_a #force fallback + Member.all.merge!(includes: :club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name").to_a # force fallback end assert_equal 1, members.size assert_not_nil assert_no_queries { members[0].club } @@ -145,7 +177,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase def test_has_one_through_nonpreload_eager_loading_through_polymorphic members = assert_queries(1) do - Member.all.merge!(includes: :sponsor_club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name").to_a #force fallback + Member.all.merge!(includes: :sponsor_club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name").to_a # force fallback end assert_equal 1, members.size assert_not_nil assert_no_queries { members[0].sponsor_club } @@ -154,7 +186,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase def test_has_one_through_nonpreload_eager_loading_through_polymorphic_with_more_than_one_through_record Sponsor.new(sponsor_club: clubs(:crazy_club), sponsorable: members(:groucho)).save! members = assert_queries(1) do - Member.all.merge!(includes: :sponsor_club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name DESC").to_a #force fallback + Member.all.merge!(includes: :sponsor_club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name DESC").to_a # force fallback end assert_equal 1, members.size assert_not_nil assert_no_queries { members[0].sponsor_club } @@ -227,7 +259,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase MemberDetail.all.merge!(includes: :member_type).to_a end @new_detail = @member_details[0] - assert @new_detail.send(:association, :member_type).loaded? + assert_predicate @new_detail.send(:association, :member_type), :loaded? assert_no_queries { @new_detail.member_type } end @@ -315,12 +347,12 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase minivan.dashboard proxy = minivan.send(:association_instance_get, :dashboard) - assert !proxy.stale_target? + assert_not_predicate proxy, :stale_target? assert_equal dashboards(:cool_first), minivan.dashboard minivan.speedometer_id = speedometers(:second).id - assert proxy.stale_target? + assert_predicate proxy, :stale_target? assert_equal dashboards(:second), minivan.dashboard end @@ -332,7 +364,7 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase minivan.speedometer_id = speedometers(:second).id - assert proxy.stale_target? + assert_predicate proxy, :stale_target? assert_equal dashboards(:second), minivan.dashboard end diff --git a/activerecord/test/cases/associations/inner_join_association_test.rb b/activerecord/test/cases/associations/inner_join_association_test.rb index 7414869c8f..c33dcdee61 100644 --- a/activerecord/test/cases/associations/inner_join_association_test.rb +++ b/activerecord/test/cases/associations/inner_join_association_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" @@ -10,7 +12,7 @@ require "models/tagging" require "models/tag" class InnerJoinAssociationTest < ActiveRecord::TestCase - fixtures :authors, :essays, :posts, :comments, :categories, :categories_posts, :categorizations, + fixtures :authors, :author_addresses, :essays, :posts, :comments, :categories, :categories_posts, :categorizations, :taggings, :tags def test_construct_finder_sql_applies_aliases_tables_on_association_conditions @@ -25,6 +27,24 @@ class InnerJoinAssociationTest < ActiveRecord::TestCase end end + 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) + end + + def test_construct_finder_sql_does_not_table_name_collide_with_string_joins + sql = Person.joins(:agents).joins("JOIN people agents_people ON agents_people.primary_contact_id = people.id").to_sql + assert_match(/agents_people_2/i, sql) + end + + def test_construct_finder_sql_does_not_table_name_collide_with_aliased_joins + people = Person.arel_table + agents = people.alias("agents_people") + constraint = agents[:primary_contact_id].eq(people[:id]) + sql = Person.joins(:agents).joins(agents.create_join(agents, agents.create_on(constraint))).to_sql + assert_match(/agents_people_2/i, sql) + end + def test_construct_finder_sql_ignores_empty_joins_hash sql = Author.joins({}).to_sql assert_no_match(/JOIN/i, sql) @@ -59,19 +79,19 @@ class InnerJoinAssociationTest < ActiveRecord::TestCase def test_find_with_implicit_inner_joins_honors_readonly_with_select authors = Author.joins(:posts).select("authors.*").to_a - assert !authors.empty?, "expected authors to be non-empty" + assert_not authors.empty?, "expected authors to be non-empty" assert authors.all? { |a| !a.readonly? }, "expected no authors to be readonly" end def test_find_with_implicit_inner_joins_honors_readonly_false authors = Author.joins(:posts).readonly(false).to_a - assert !authors.empty?, "expected authors to be non-empty" + assert_not authors.empty?, "expected authors to be non-empty" assert authors.all? { |a| !a.readonly? }, "expected no authors to be readonly" end def test_find_with_implicit_inner_joins_does_not_set_associations authors = Author.joins(:posts).select("authors.*").to_a - assert !authors.empty?, "expected authors to be non-empty" + assert_not authors.empty?, "expected authors to be non-empty" assert authors.all? { |a| !a.instance_variable_defined?(:@posts) }, "expected no authors to have the @posts association loaded" end @@ -95,19 +115,19 @@ class InnerJoinAssociationTest < ActiveRecord::TestCase scope = Post.joins(:special_comments).where(id: posts(:sti_comments).id) # The join should match SpecialComment and its subclasses only - assert scope.where("comments.type" => "Comment").empty? - assert !scope.where("comments.type" => "SpecialComment").empty? - assert !scope.where("comments.type" => "SubSpecialComment").empty? + assert_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + assert_not_empty scope.where("comments.type" => "SubSpecialComment") end def test_find_with_conditions_on_reflection - assert !posts(:welcome).comments.empty? + assert_not_empty posts(:welcome).comments assert Post.joins(:nonexistent_comments).where(id: posts(:welcome).id).empty? # [sic!] end def test_find_with_conditions_on_through_reflection - assert !posts(:welcome).tags.empty? - assert Post.joins(:misc_tags).where(id: posts(:welcome).id).empty? + assert_not_empty posts(:welcome).tags + assert_empty Post.joins(:misc_tags).where(id: posts(:welcome).id) end test "the default scope of the target is applied when joining associations" do diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb index 287b3e9ebc..eb4dc73423 100644 --- a/activerecord/test/cases/associations/inverse_associations_test.rb +++ b/activerecord/test/cases/associations/inverse_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/man" require "models/face" @@ -16,6 +18,10 @@ require "models/admin/user" require "models/developer" require "models/company" require "models/project" +require "models/author" +require "models/post" +require "models/department" +require "models/hotel" class AutomaticInverseFindingTests < ActiveRecord::TestCase fixtures :ratings, :comments, :cars @@ -24,11 +30,9 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase monkey_reflection = MixedCaseMonkey.reflect_on_association(:man) man_reflection = Man.reflect_on_association(:mixed_case_monkey) - assert_respond_to monkey_reflection, :has_inverse? assert monkey_reflection.has_inverse?, "The monkey reflection should have an inverse" assert_equal man_reflection, monkey_reflection.inverse_of, "The monkey reflection's inverse should be the man reflection" - assert_respond_to man_reflection, :has_inverse? assert man_reflection.has_inverse?, "The man reflection should have an inverse" assert_equal monkey_reflection, man_reflection.inverse_of, "The man reflection's inverse should be the monkey reflection" end @@ -37,7 +41,6 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase account_reflection = Admin::Account.reflect_on_association(:users) user_reflection = Admin::User.reflect_on_association(:account) - assert_respond_to account_reflection, :has_inverse? assert account_reflection.has_inverse?, "The Admin::Account reflection should have an inverse" assert_equal user_reflection, account_reflection.inverse_of, "The Admin::Account reflection's inverse should be the Admin::User reflection" end @@ -46,11 +49,9 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase car_reflection = Car.reflect_on_association(:bulb) bulb_reflection = Bulb.reflect_on_association(:car) - assert_respond_to car_reflection, :has_inverse? assert car_reflection.has_inverse?, "The Car reflection should have an inverse" assert_equal bulb_reflection, car_reflection.inverse_of, "The Car reflection's inverse should be the Bulb reflection" - assert_respond_to bulb_reflection, :has_inverse? assert bulb_reflection.has_inverse?, "The Bulb reflection should have an inverse" assert_equal car_reflection, bulb_reflection.inverse_of, "The Bulb reflection's inverse should be the Car reflection" end @@ -59,11 +60,24 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase comment_reflection = Comment.reflect_on_association(:ratings) rating_reflection = Rating.reflect_on_association(:comment) - assert_respond_to comment_reflection, :has_inverse? assert comment_reflection.has_inverse?, "The Comment reflection should have an inverse" 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_sti + author_reflection = Author.reflect_on_association(:posts) + author_child_reflection = Author.reflect_on_association(:special_posts) + post_reflection = Post.reflect_on_association(:author) + + assert_respond_to author_reflection, :has_inverse? + assert author_reflection.has_inverse?, "The Author reflection should have an inverse" + assert_equal post_reflection, author_reflection.inverse_of, "The Author reflection's inverse should be the Post reflection" + + assert_respond_to author_child_reflection, :has_inverse? + assert author_child_reflection.has_inverse?, "The Author reflection should have an inverse" + assert_equal post_reflection, author_child_reflection.inverse_of, "The Author reflection's inverse should be the Post reflection" + end + def test_has_one_and_belongs_to_automatic_inverse_shares_objects car = Car.first bulb = Bulb.create!(car: car) @@ -107,24 +121,17 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase def test_polymorphic_and_has_many_through_relationships_should_not_have_inverses sponsor_reflection = Sponsor.reflect_on_association(:sponsorable) - assert_respond_to sponsor_reflection, :has_inverse? - assert !sponsor_reflection.has_inverse?, "A polymorphic association should not find an inverse automatically" + assert_not sponsor_reflection.has_inverse?, "A polymorphic association should not find an inverse automatically" club_reflection = Club.reflect_on_association(:members) - assert_respond_to club_reflection, :has_inverse? - assert !club_reflection.has_inverse?, "A has_many_through association should not find an inverse automatically" + assert_not club_reflection.has_inverse?, "A has_many_through association should not find an inverse automatically" end - def test_polymorphic_relationships_should_still_not_have_inverses_when_non_polymorphic_relationship_has_the_same_name + def test_polymorphic_has_one_should_find_inverse_automatically man_reflection = Man.reflect_on_association(:polymorphic_face_without_inverse) - face_reflection = Face.reflect_on_association(:man) - - assert_respond_to face_reflection, :has_inverse? - assert face_reflection.has_inverse?, "For this test, the non-polymorphic association must have an inverse" - assert_respond_to man_reflection, :has_inverse? - assert !man_reflection.has_inverse?, "The target of a polymorphic association should not find an inverse automatically" + assert_predicate man_reflection, :has_inverse? end end @@ -145,39 +152,22 @@ class InverseAssociationTests < ActiveRecord::TestCase def test_should_be_able_to_ask_a_reflection_if_it_has_an_inverse has_one_with_inverse_ref = Man.reflect_on_association(:face) - assert_respond_to has_one_with_inverse_ref, :has_inverse? - assert has_one_with_inverse_ref.has_inverse? + assert_predicate has_one_with_inverse_ref, :has_inverse? has_many_with_inverse_ref = Man.reflect_on_association(:interests) - assert_respond_to has_many_with_inverse_ref, :has_inverse? - assert has_many_with_inverse_ref.has_inverse? + assert_predicate has_many_with_inverse_ref, :has_inverse? belongs_to_with_inverse_ref = Face.reflect_on_association(:man) - assert_respond_to belongs_to_with_inverse_ref, :has_inverse? - assert belongs_to_with_inverse_ref.has_inverse? + assert_predicate belongs_to_with_inverse_ref, :has_inverse? has_one_without_inverse_ref = Club.reflect_on_association(:sponsor) - assert_respond_to has_one_without_inverse_ref, :has_inverse? - assert !has_one_without_inverse_ref.has_inverse? + assert_not_predicate has_one_without_inverse_ref, :has_inverse? has_many_without_inverse_ref = Club.reflect_on_association(:memberships) - assert_respond_to has_many_without_inverse_ref, :has_inverse? - assert !has_many_without_inverse_ref.has_inverse? + assert_not_predicate has_many_without_inverse_ref, :has_inverse? belongs_to_without_inverse_ref = Sponsor.reflect_on_association(:sponsor_club) - assert_respond_to belongs_to_without_inverse_ref, :has_inverse? - assert !belongs_to_without_inverse_ref.has_inverse? - end - - def test_should_be_able_to_ask_a_reflection_what_it_is_the_inverse_of - has_one_ref = Man.reflect_on_association(:face) - assert_respond_to has_one_ref, :inverse_of - - has_many_ref = Man.reflect_on_association(:interests) - assert_respond_to has_many_ref, :inverse_of - - belongs_to_ref = Face.reflect_on_association(:man) - assert_respond_to belongs_to_ref, :inverse_of + assert_not_predicate belongs_to_without_inverse_ref, :has_inverse? end def test_inverse_of_method_should_supply_the_actual_reflection_instance_it_is_the_inverse_of @@ -202,6 +192,16 @@ class InverseAssociationTests < ActiveRecord::TestCase assert_nil belongs_to_ref.inverse_of end + def test_polymorphic_associations_dont_attempt_to_find_inverse_of + belongs_to_ref = Sponsor.reflect_on_association(:sponsor) + assert_raise(ArgumentError) { belongs_to_ref.klass } + assert_nil belongs_to_ref.inverse_of + + belongs_to_ref = Face.reflect_on_association(:human) + assert_raise(ArgumentError) { belongs_to_ref.klass } + assert_nil belongs_to_ref.inverse_of + end + def test_this_inverse_stuff firm = Firm.create!(name: "Adequate Holdings") Project.create!(name: "Project 1", firm: firm) @@ -295,7 +295,7 @@ class InverseHasOneTests < ActiveRecord::TestCase end class InverseHasManyTests < ActiveRecord::TestCase - fixtures :men, :interests + fixtures :men, :interests, :posts, :authors, :author_addresses def test_parent_instance_should_be_shared_with_every_child_on_find m = men(:gordon) @@ -309,6 +309,27 @@ class InverseHasManyTests < ActiveRecord::TestCase end end + def test_parent_instance_should_be_shared_with_every_child_on_find_for_sti + a = authors(:david) + ps = a.posts + ps.each do |p| + assert_equal a.name, p.author.name, "Name of man should be the same before changes to parent instance" + a.name = "Bongo" + assert_equal a.name, p.author.name, "Name of man should be the same after changes to parent instance" + p.author.name = "Mungo" + assert_equal a.name, p.author.name, "Name of man should be the same after changes to child-owned instance" + end + + sps = a.special_posts + sps.each do |sp| + assert_equal a.name, sp.author.name, "Name of man should be the same before changes to parent instance" + a.name = "Bongo" + assert_equal a.name, sp.author.name, "Name of man should be the same after changes to parent instance" + sp.author.name = "Mungo" + assert_equal a.name, sp.author.name, "Name of man should be the same after changes to child-owned instance" + end + end + def test_parent_instance_should_be_shared_with_eager_loaded_children m = Man.all.merge!(where: { name: "Gordon" }, includes: :interests).first is = m.interests @@ -455,7 +476,7 @@ class InverseHasManyTests < ActiveRecord::TestCase interest = Interest.create!(man: man) man.interests.find(interest.id) - assert_not man.interests.loaded? + assert_not_predicate man.interests, :loaded? end def test_raise_record_not_found_error_when_invalid_ids_are_passed @@ -475,7 +496,10 @@ class InverseHasManyTests < ActiveRecord::TestCase def test_raise_record_not_found_error_when_no_ids_are_passed man = Man.create! - assert_raise(ActiveRecord::RecordNotFound) { man.interests.find() } + exception = assert_raise(ActiveRecord::RecordNotFound) { man.interests.load.find() } + + assert_equal exception.model, "Interest" + assert_equal exception.primary_key, "id" end def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error @@ -492,16 +516,16 @@ class InverseHasManyTests < ActiveRecord::TestCase i.man.name = "Charles" assert_equal i.man.name, man.name - assert !man.persisted? + assert_not_predicate man, :persisted? end def test_inverse_instance_should_be_set_before_find_callbacks_are_run reset_callbacks(Interest, :find) do Interest.after_find { raise unless association(:man).loaded? && man.present? } - assert Man.first.interests.reload.any? - assert Man.includes(:interests).first.interests.any? - assert Man.joins(:interests).includes(:interests).first.interests.any? + assert_predicate Man.first.interests.reload, :any? + assert_predicate Man.includes(:interests).first.interests, :any? + assert_predicate Man.joins(:interests).includes(:interests).first.interests, :any? end end @@ -509,9 +533,9 @@ class InverseHasManyTests < ActiveRecord::TestCase reset_callbacks(Interest, :initialize) do Interest.after_initialize { raise unless association(:man).loaded? && man.present? } - assert Man.first.interests.reload.any? - assert Man.includes(:interests).first.interests.any? - assert Man.joins(:interests).includes(:interests).first.interests.any? + assert_predicate Man.first.interests.reload, :any? + assert_predicate Man.includes(:interests).first.interests, :any? + assert_predicate Man.joins(:interests).includes(:interests).first.interests, :any? end end @@ -651,20 +675,6 @@ class InversePolymorphicBelongsToTests < ActiveRecord::TestCase assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to replaced-parent-owned instance" end - def test_child_instance_should_be_shared_with_replaced_via_method_parent - face = faces(:confused) - new_man = Man.new - - assert_not_nil face.polymorphic_man - face.polymorphic_man = new_man - - assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same before changes to parent instance" - face.description = "Bongo" - assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to parent instance" - new_man.polymorphic_face.description = "Mungo" - assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to replaced-parent-owned instance" - end - def test_inversed_instance_should_not_be_reloaded_after_stale_state_changed new_man = Man.new face = Face.new @@ -677,6 +687,16 @@ class InversePolymorphicBelongsToTests < ActiveRecord::TestCase assert_equal old_inversed_man.object_id, new_inversed_man.object_id end + def test_inversed_instance_should_not_be_reloaded_after_stale_state_changed_with_validation + face = Face.new man: Man.new + + old_inversed_man = face.man + face.save! + new_inversed_man = face.man + + assert_equal old_inversed_man.object_id, new_inversed_man.object_id + end + def test_should_not_try_to_set_inverse_instances_when_the_inverse_is_a_has_many i = interests(:llama_wrangling) m = i.polymorphic_man @@ -706,6 +726,16 @@ class InversePolymorphicBelongsToTests < ActiveRecord::TestCase # fails because Interest does have the correct inverse_of assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.polymorphic_man = Interest.first } end + + def test_favors_has_one_associations_for_inverse_of + inverse_name = Post.reflect_on_association(:author).inverse_of.name + assert_equal :post, inverse_name + end + + def test_finds_inverse_of_for_plural_associations + inverse_name = Department.reflect_on_association(:hotel).inverse_of.name + assert_equal :departments, inverse_name + end end # NOTE - these tests might not be meaningful, ripped as they were from the parental_control plugin diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index a4345f3857..9d1c73c33b 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/tag" require "models/tagging" @@ -19,7 +21,7 @@ require "models/car" class AssociationsJoinModelTest < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? - fixtures :posts, :authors, :categories, :categorizations, :comments, :tags, :taggings, :author_favorites, :vertices, :items, :books, + fixtures :posts, :authors, :author_addresses, :categories, :categorizations, :comments, :tags, :taggings, :author_favorites, :vertices, :items, :books, # Reload edges table from fixtures as otherwise repeated test was failing :edges @@ -42,11 +44,11 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase def test_has_many_distinct_through_count author = authors(:mary) - assert !authors(:mary).unique_categorized_posts.loaded? + assert_not_predicate authors(:mary).unique_categorized_posts, :loaded? assert_queries(1) { assert_equal 1, author.unique_categorized_posts.count } assert_queries(1) { assert_equal 1, author.unique_categorized_posts.count(:title) } assert_queries(1) { assert_equal 0, author.unique_categorized_posts.where(title: nil).count(:title) } - assert !authors(:mary).unique_categorized_posts.loaded? + assert_not_predicate authors(:mary).unique_categorized_posts, :loaded? end def test_has_many_distinct_through_find @@ -97,11 +99,11 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase end def test_polymorphic_has_many_create_model_with_inheritance_and_custom_base_class - post = SubStiPost.create title: "SubStiPost", body: "SubStiPost body" - assert_instance_of SubStiPost, post + post = SubAbstractStiPost.create title: "SubAbstractStiPost", body: "SubAbstractStiPost body" + assert_instance_of SubAbstractStiPost, post tagging = tags(:misc).taggings.create(taggable: post) - assert_equal "SubStiPost", tagging.taggable_type + assert_equal "SubAbstractStiPost", tagging.taggable_type end def test_polymorphic_has_many_going_through_join_model_with_inheritance @@ -367,7 +369,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase Tag.has_many :null_taggings, -> { none }, class_name: :Tagging Tag.has_many :null_tagged_posts, through: :null_taggings, source: "taggable", source_type: "Post" assert_equal [], tags(:general).null_tagged_posts - refute_equal [], tags(:general).tagged_posts + assert_not_equal [], tags(:general).tagged_posts end def test_eager_has_many_polymorphic_with_source_type @@ -402,7 +404,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase end def test_has_many_through_polymorphic_has_one - assert_equal Tagging.find(1, 2).sort_by(&:id), authors(:david).taggings_2 + assert_equal Tagging.find(1, 2).sort_by(&:id), authors(:david).taggings_2.sort_by(&:id) end def test_has_many_through_polymorphic_has_many @@ -452,8 +454,8 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase def test_has_many_through_uses_conditions_specified_on_the_has_many_association author = Author.first - assert author.comments.present? - assert author.nonexistent_comments.blank? + assert_predicate author.comments, :present? + assert_predicate author.nonexistent_comments, :blank? end def test_has_many_through_uses_correct_attributes @@ -465,27 +467,27 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase new_tag = Tag.new(name: "new") saved_post.tags << new_tag - assert new_tag.persisted? #consistent with habtm! - assert saved_post.persisted? + assert new_tag.persisted? # consistent with habtm! + assert_predicate saved_post, :persisted? assert_includes saved_post.tags, new_tag - assert new_tag.persisted? + assert_predicate new_tag, :persisted? assert_includes saved_post.reload.tags.reload, new_tag new_post = Post.new(title: "Association replacement works!", body: "You best believe it.") saved_tag = tags(:general) new_post.tags << saved_tag - assert !new_post.persisted? - assert saved_tag.persisted? + assert_not_predicate new_post, :persisted? + assert_predicate saved_tag, :persisted? assert_includes new_post.tags, saved_tag new_post.save! - assert new_post.persisted? + assert_predicate new_post, :persisted? assert_includes new_post.reload.tags.reload, saved_tag - assert !posts(:thinking).tags.build.persisted? - assert !posts(:thinking).tags.new.persisted? + assert_not_predicate posts(:thinking).tags.build, :persisted? + assert_not_predicate posts(:thinking).tags.new, :persisted? end def test_create_associate_when_adding_to_has_many_through @@ -494,25 +496,25 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase post_thinking = posts(:thinking) assert_nothing_raised { post_thinking.tags << push } assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag }, - message = "Expected a Tag in tags collection, got #{wrong.class}.") + "Expected a Tag in tags collection, got #{wrong.class}.") assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, - message = "Expected a Tagging in taggings collection, got #{wrong.class}.") + "Expected a Tagging in taggings collection, got #{wrong.class}.") assert_equal(count + 1, post_thinking.reload.tags.size) assert_equal(count + 1, post_thinking.tags.reload.size) assert_kind_of Tag, post_thinking.tags.create!(name: "foo") assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag }, - message = "Expected a Tag in tags collection, got #{wrong.class}.") + "Expected a Tag in tags collection, got #{wrong.class}.") assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, - message = "Expected a Tagging in taggings collection, got #{wrong.class}.") + "Expected a Tagging in taggings collection, got #{wrong.class}.") assert_equal(count + 2, post_thinking.reload.tags.size) assert_equal(count + 2, post_thinking.tags.reload.size) assert_nothing_raised { post_thinking.tags.concat(Tag.create!(name: "abc"), Tag.create!(name: "def")) } assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag }, - message = "Expected a Tag in tags collection, got #{wrong.class}.") + "Expected a Tag in tags collection, got #{wrong.class}.") assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, - message = "Expected a Tagging in taggings collection, got #{wrong.class}.") + "Expected a Tagging in taggings collection, got #{wrong.class}.") assert_equal(count + 4, post_thinking.reload.tags.size) assert_equal(count + 4, post_thinking.tags.reload.size) @@ -527,14 +529,14 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase def test_has_many_through_collection_size_doesnt_load_target_if_not_loaded author = authors(:david) assert_equal 10, author.comments.size - assert !author.comments.loaded? + assert_not_predicate author.comments, :loaded? end def test_has_many_through_collection_size_uses_counter_cache_if_it_exists c = categories(:general) c.categorizations_count = 100 assert_equal 100, c.categorizations.size - assert !c.categorizations.loaded? + assert_not_predicate c.categorizations, :loaded? end def test_adding_junk_to_has_many_through_should_raise_type_mismatch @@ -708,7 +710,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase category = david.categories.first assert_no_queries do - assert david.categories.loaded? + assert_predicate david.categories, :loaded? assert_includes david.categories, category end end @@ -718,19 +720,19 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase category = david.categories.first david.reload - assert ! david.categories.loaded? + assert_not_predicate david.categories, :loaded? assert_queries(1) do assert_includes david.categories, category end - assert ! david.categories.loaded? + assert_not_predicate david.categories, :loaded? end def test_has_many_through_include_returns_false_for_non_matching_record_to_verify_scoping david = authors(:david) category = Category.create!(name: "Not Associated") - assert ! david.categories.loaded? - assert ! david.categories.include?(category) + assert_not_predicate david.categories, :loaded? + assert_not david.categories.include?(category) end def test_has_many_through_goes_through_all_sti_classes 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 42dbbad1c8..0e54e8c1b0 100644 --- a/activerecord/test/cases/associations/left_outer_join_association_test.rb +++ b/activerecord/test/cases/associations/left_outer_join_association_test.rb @@ -1,13 +1,16 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" require "models/author" require "models/essay" +require "models/category" require "models/categorization" require "models/person" class LeftOuterJoinAssociationTest < ActiveRecord::TestCase - fixtures :authors, :essays, :posts, :comments, :categorizations, :people + fixtures :authors, :author_addresses, :essays, :posts, :comments, :categorizations, :people def test_construct_finder_sql_applies_aliases_tables_on_association_conditions result = Author.left_outer_joins(:thinking_posts, :welcome_posts).to_a @@ -67,15 +70,15 @@ class LeftOuterJoinAssociationTest < ActiveRecord::TestCase scope = Post.left_outer_joins(:special_comments).where(id: posts(:sti_comments).id) # The join should match SpecialComment and its subclasses only - assert scope.where("comments.type" => "Comment").empty? - assert !scope.where("comments.type" => "SpecialComment").empty? - assert !scope.where("comments.type" => "SubSpecialComment").empty? + assert_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + assert_not_empty scope.where("comments.type" => "SubSpecialComment") end def test_does_not_override_select authors = Author.select("authors.name, #{%{(authors.author_address_id || ' ' || authors.author_address_extra_id) as addr_id}}").left_outer_joins(:posts) - assert authors.any? - assert authors.first.respond_to?(:addr_id) + assert_predicate authors, :any? + assert_respond_to authors.first, :addr_id end test "the default scope of the target is applied when joining associations" do diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index dc26f6a383..03ed1c1d47 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/author" require "models/post" @@ -22,9 +24,14 @@ require "models/category" require "models/categorization" require "models/membership" require "models/essay" +require "models/hotel" +require "models/department" +require "models/chef" +require "models/cake_designer" +require "models/drink_designer" class NestedThroughAssociationsTest < ActiveRecord::TestCase - fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, + fixtures :authors, :author_addresses, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, :categorizations, :memberships, :essays @@ -71,7 +78,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase # This ensures that the polymorphism of taggings is being observed correctly authors = Author.joins(:tags).where("taggings.taggable_type" => "FakeModel") - assert authors.empty? + assert_empty authors end # has_many through @@ -170,7 +177,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase members = Member.joins(:organization_member_details). where("member_details.id" => 9) - assert members.empty? + assert_empty members end # has_many through @@ -202,7 +209,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase members = Member.joins(:organization_member_details_2). where("member_details.id" => 9) - assert members.empty? + assert_empty members end # has_many through @@ -418,9 +425,14 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase # Check the polymorphism of taggings is being observed correctly (in both joins) authors = Author.joins(:similar_posts).where("taggings.taggable_type" => "FakeModel") - assert authors.empty? + assert_empty authors authors = Author.joins(:similar_posts).where("taggings_authors_join.taggable_type" => "FakeModel") - assert authors.empty? + assert_empty authors + end + + def test_nested_has_many_through_with_scope_on_polymorphic_reflection + authors = Author.joins(:ordered_posts).where("posts.id" => posts(:misc_by_bob).id) + assert_equal [authors(:mary), authors(:bob)], authors.distinct.sort_by(&:id) end def test_has_many_through_with_foreign_key_option_on_through_reflection @@ -444,9 +456,9 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase # Ensure STI is respected in the join scope = Post.joins(:special_comments_ratings).where(id: posts(:sti_comments).id) - assert scope.where("comments.type" => "Comment").empty? - assert !scope.where("comments.type" => "SpecialComment").empty? - assert !scope.where("comments.type" => "SubSpecialComment").empty? + assert_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + assert_not_empty scope.where("comments.type" => "SubSpecialComment") end def test_has_many_through_with_sti_on_nested_through_reflection @@ -454,8 +466,8 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase assert_equal [taggings(:special_comment_rating)], taggings scope = Post.joins(:special_comments_ratings_taggings).where(id: posts(:sti_comments).id) - assert scope.where("comments.type" => "Comment").empty? - assert !scope.where("comments.type" => "SpecialComment").empty? + assert_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") end def test_nested_has_many_through_writers_should_raise_error @@ -505,7 +517,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase end def test_nested_has_many_through_with_conditions_on_through_associations_preload - assert Author.where("tags.id" => 100).joins(:misc_post_first_blue_tags).empty? + assert_empty Author.where("tags.id" => 100).joins(:misc_post_first_blue_tags) authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) } blue = tags(:blue) @@ -562,9 +574,40 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase c = Categorization.new c.author = authors(:david) c.post_taggings.to_a - assert !c.post_taggings.empty? + assert_not_empty c.post_taggings c.save - assert !c.post_taggings.empty? + assert_not_empty c.post_taggings + end + + def test_polymorphic_has_many_through_when_through_association_has_not_loaded + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + Hotel.create!(departments: [department]) + hotel = Hotel.includes(:cake_designers, :drink_designers).take + + assert_equal [cake_designer], hotel.cake_designers + assert_equal [drink_designer], hotel.drink_designers + end + + def test_polymorphic_has_many_through_when_through_association_has_already_loaded + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + Hotel.create!(departments: [department]) + hotel = Hotel.includes(:chefs, :cake_designers, :drink_designers).take + + assert_equal [cake_designer], hotel.cake_designers + assert_equal [drink_designer], hotel.drink_designers + end + + def test_polymorphic_has_many_through_joined_different_table_twice + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + hotel = Hotel.create!(departments: [department]) + + assert_equal hotel, Hotel.joins(:cake_designers, :drink_designers).take end private diff --git a/activerecord/test/cases/associations/required_test.rb b/activerecord/test/cases/associations/required_test.rb index f8b686721e..65a3bb5efe 100644 --- a/activerecord/test/cases/associations/required_test.rb +++ b/activerecord/test/cases/associations/required_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class RequiredAssociationsTest < ActiveRecord::TestCase @@ -22,14 +24,21 @@ class RequiredAssociationsTest < ActiveRecord::TestCase @connection.drop_table "children", if_exists: true end - test "belongs_to associations are not required by default" do - model = subclass_of(Child) do - belongs_to :parent, inverse_of: false, - class_name: "RequiredAssociationsTest::Parent" - end + test "belongs_to associations can be optional by default" do + begin + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = false - assert model.new.save - assert model.new(parent: Parent.new).save + model = subclass_of(Child) do + belongs_to :parent, inverse_of: false, + class_name: "RequiredAssociationsTest::Parent" + end + + assert model.new.save + assert model.new(parent: Parent.new).save + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + end end test "required belongs_to associations have presence validated" do @@ -46,6 +55,27 @@ class RequiredAssociationsTest < ActiveRecord::TestCase assert record.save end + test "belongs_to associations can be required by default" do + begin + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = true + + model = subclass_of(Child) do + belongs_to :parent, inverse_of: false, + class_name: "RequiredAssociationsTest::Parent" + end + + record = model.new + assert_not record.save + assert_equal ["Parent must exist"], record.errors.full_messages + + record.parent = Parent.new + assert record.save + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + end + end + test "has_one associations are not required by default" do model = subclass_of(Parent) do has_one :child, inverse_of: false, diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index 26056f6f63..739eb02e0c 100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/computer" require "models/developer" @@ -22,7 +24,7 @@ require "models/interest" class AssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :developers_projects, - :computers, :people, :readers, :authors, :author_favorites + :computers, :people, :readers, :authors, :author_addresses, :author_favorites def test_eager_loading_should_not_change_count_of_children liquid = Liquid.create(name: "salty") @@ -45,7 +47,7 @@ class AssociationsTest < ActiveRecord::TestCase ship = Ship.create!(name: "The good ship Dollypop") part = ship.parts.create!(name: "Mast") part.mark_for_destruction - assert ship.parts[0].marked_for_destruction? + assert_predicate ship.parts[0], :marked_for_destruction? end def test_loading_the_association_target_should_load_most_recent_attributes_for_child_records_marked_for_destruction @@ -90,7 +92,7 @@ class AssociationsTest < ActiveRecord::TestCase firm.clients.reload - assert !firm.clients.empty?, "New firm should have reloaded client objects" + assert_not firm.clients.empty?, "New firm should have reloaded client objects" assert_equal 1, firm.clients.size, "New firm should have reloaded clients count" end @@ -100,8 +102,8 @@ class AssociationsTest < ActiveRecord::TestCase has_many_reflections = [Tag.reflect_on_association(:taggings), Developer.reflect_on_association(:projects)] mixed_reflections = (belongs_to_reflections + has_many_reflections).uniq assert using_limitable_reflections.call(belongs_to_reflections), "Belong to associations are limitable" - assert !using_limitable_reflections.call(has_many_reflections), "All has many style associations are not limitable" - assert !using_limitable_reflections.call(mixed_reflections), "No collection associations (has many style) should pass" + assert_not using_limitable_reflections.call(has_many_reflections), "All has many style associations are not limitable" + assert_not using_limitable_reflections.call(mixed_reflections), "No collection associations (has many style) should pass" end def test_association_with_references @@ -111,13 +113,13 @@ class AssociationsTest < ActiveRecord::TestCase end class AssociationProxyTest < ActiveRecord::TestCase - fixtures :authors, :posts, :categorizations, :categories, :developers, :projects, :developers_projects + fixtures :authors, :author_addresses, :posts, :categorizations, :categories, :developers, :projects, :developers_projects def test_push_does_not_load_target david = authors(:david) david.posts << (post = Post.new(title: "New on Edge", body: "More cool stuff!")) - assert !david.posts.loaded? + assert_not_predicate david.posts, :loaded? assert_includes david.posts, post end @@ -125,7 +127,7 @@ class AssociationProxyTest < ActiveRecord::TestCase david = authors(:david) david.categories << categories(:technology) - assert !david.categories.loaded? + assert_not_predicate david.categories, :loaded? assert_includes david.categories, categories(:technology) end @@ -133,23 +135,23 @@ class AssociationProxyTest < ActiveRecord::TestCase david = authors(:david) david.posts << (post = Post.new(title: "New on Edge", body: "More cool stuff!")) - assert !david.posts.loaded? + assert_not_predicate david.posts, :loaded? david.save - assert !david.posts.loaded? + assert_not_predicate david.posts, :loaded? assert_includes david.posts, post end def test_push_does_not_lose_additions_to_new_record josh = Author.new(name: "Josh") josh.posts << Post.new(title: "New on Edge", body: "More cool stuff!") - assert josh.posts.loaded? + assert_predicate josh.posts, :loaded? assert_equal 1, josh.posts.size end def test_append_behaves_like_push josh = Author.new(name: "Josh") josh.posts.append Post.new(title: "New on Edge", body: "More cool stuff!") - assert josh.posts.loaded? + assert_predicate josh.posts, :loaded? assert_equal 1, josh.posts.size end @@ -161,22 +163,22 @@ class AssociationProxyTest < ActiveRecord::TestCase def test_save_on_parent_does_not_load_target david = developers(:david) - assert !david.projects.loaded? + assert_not_predicate david.projects, :loaded? david.update_columns(created_at: Time.now) - assert !david.projects.loaded? + assert_not_predicate david.projects, :loaded? end def test_load_does_load_target david = developers(:david) - assert !david.projects.loaded? + assert_not_predicate david.projects, :loaded? david.projects.load - assert david.projects.loaded? + assert_predicate david.projects, :loaded? end def test_inspect_does_not_reload_a_not_yet_loaded_target andreas = Developer.new name: "Andreas", log: "new developer added" - assert !andreas.audit_logs.loaded? + assert_not_predicate andreas.audit_logs, :loaded? assert_match(/message: "new developer added"/, andreas.audit_logs.inspect) end @@ -220,6 +222,18 @@ class AssociationProxyTest < ActiveRecord::TestCase assert_equal david.projects, david.projects.scope end + test "proxy object is cached" do + david = developers(:david) + assert_same david.projects, david.projects + end + + test "proxy object can be stubbed" do + david = developers(:david) + david.projects.define_singleton_method(:extra_method) { 42 } + + assert_equal 42, david.projects.extra_method + end + test "inverses get set of subsets of the association" do man = Man.create man.interests.create @@ -233,25 +247,25 @@ class AssociationProxyTest < ActiveRecord::TestCase test "first! works on loaded associations" do david = authors(:david) - assert_equal david.posts.first, david.posts.reload.first! - assert david.posts.loaded? - assert_no_queries { david.posts.first! } + assert_equal david.first_posts.first, david.first_posts.reload.first! + assert_predicate david.first_posts, :loaded? + assert_no_queries { david.first_posts.first! } end def test_pluck_uses_loaded_target david = authors(:david) - assert_equal david.posts.pluck(:title), david.posts.load.pluck(:title) - assert david.posts.loaded? - assert_no_queries { david.posts.pluck(:title) } + assert_equal david.first_posts.pluck(:title), david.first_posts.load.pluck(:title) + assert_predicate david.first_posts, :loaded? + assert_no_queries { david.first_posts.pluck(:title) } end def test_reset_unloads_target david = authors(:david) david.posts.reload - assert david.posts.loaded? + assert_predicate david.posts, :loaded? david.posts.reset - assert !david.posts.loaded? + assert_not_predicate david.posts, :loaded? end end diff --git a/activerecord/test/cases/attribute_decorators_test.rb b/activerecord/test/cases/attribute_decorators_test.rb index cfa6ed1da6..42eca233ce 100644 --- a/activerecord/test/cases/attribute_decorators_test.rb +++ b/activerecord/test/cases/attribute_decorators_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/attribute_methods/read_test.rb b/activerecord/test/cases/attribute_methods/read_test.rb index 1fc63a49d4..54512068ee 100644 --- a/activerecord/test/cases/attribute_methods/read_test.rb +++ b/activerecord/test/cases/attribute_methods/read_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -8,11 +10,10 @@ module ActiveRecord end def setup - @klass = Class.new do + @klass = Class.new(Class.new { def self.initialize_generated_modules; end }) do def self.superclass; Base; end - def self.base_class; self; end + def self.base_class?; true; end def self.decorate_matching_attribute_types(*); end - def self.initialize_generated_modules; end include ActiveRecord::DefineCallbacks include ActiveRecord::AttributeMethods diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index 4d24a980dc..0bfd46a522 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/minimalistic" require "models/developer" @@ -61,8 +63,8 @@ class AttributeMethodsTest < ActiveRecord::TestCase t.author_name = "" assert t.attribute_present?("title") assert t.attribute_present?("written_on") - assert !t.attribute_present?("content") - assert !t.attribute_present?("author_name") + assert_not t.attribute_present?("content") + assert_not t.attribute_present?("author_name") end test "attribute_present with booleans" do @@ -75,7 +77,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert b2.attribute_present?(:value) b3 = Boolean.new - assert !b3.attribute_present?(:value) + assert_not b3.attribute_present?(:value) b4 = Boolean.new b4.value = false @@ -97,8 +99,8 @@ class AttributeMethodsTest < ActiveRecord::TestCase end test "boolean attributes" do - assert !Topic.find(1).approved? - assert Topic.find(2).approved? + assert_not_predicate Topic.find(1), :approved? + assert_predicate Topic.find(2), :approved? end test "set attributes" do @@ -140,16 +142,16 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_respond_to topic, :title= assert_respond_to topic, "author_name" assert_respond_to topic, "attribute_names" - assert !topic.respond_to?("nothingness") - assert !topic.respond_to?(:nothingness) + assert_not_respond_to topic, "nothingness" + assert_not_respond_to topic, :nothingness end test "respond_to? with a custom primary key" do keyboard = Keyboard.create assert_not_nil keyboard.key_number assert_equal keyboard.key_number, keyboard.id - assert keyboard.respond_to?("key_number") - assert keyboard.respond_to?("id") + assert_respond_to keyboard, "key_number" + assert_respond_to keyboard, "id" end test "id_before_type_cast with a custom primary key" do @@ -161,19 +163,6 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal "10", keyboard.read_attribute_before_type_cast(:key_number) end - # Syck calls respond_to? before actually calling initialize. - test "respond_to? with an allocated object" do - klass = Class.new(ActiveRecord::Base) do - self.table_name = "topics" - end - - topic = klass.allocate - assert !topic.respond_to?("nothingness") - assert !topic.respond_to?(:nothingness) - assert_respond_to topic, "title" - assert_respond_to topic, :title - end - # IRB inspects the return value of MyModel.allocate. test "allocated objects can be inspected" do topic = Topic.allocate @@ -198,12 +187,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase if current_adapter?(:Mysql2Adapter) test "read attributes_before_type_cast on a boolean" do bool = Boolean.create!("value" => false) - if RUBY_PLATFORM.include?("java") - # JRuby will return the value before typecast as string. - assert_equal "0", bool.reload.attributes_before_type_cast["value"] - else - assert_equal 0, bool.reload.attributes_before_type_cast["value"] - end + assert_equal 0, bool.reload.attributes_before_type_cast["value"] end end @@ -357,9 +341,9 @@ class AttributeMethodsTest < ActiveRecord::TestCase test "read_attribute when false" do topic = topics(:first) topic.approved = false - assert !topic.approved?, "approved should be false" + assert_not topic.approved?, "approved should be false" topic.approved = "false" - assert !topic.approved?, "approved should be false" + assert_not topic.approved?, "approved should be false" end test "read_attribute when true" do @@ -373,10 +357,10 @@ class AttributeMethodsTest < ActiveRecord::TestCase test "boolean attributes writing and reading" do topic = Topic.new topic.approved = "false" - assert !topic.approved?, "approved should be false" + assert_not topic.approved?, "approved should be false" topic.approved = "false" - assert !topic.approved?, "approved should be false" + assert_not topic.approved?, "approved should be false" topic.approved = "true" assert topic.approved?, "approved should be true" @@ -460,30 +444,30 @@ class AttributeMethodsTest < ActiveRecord::TestCase SQL assert_equal "Firm", object.string_value - assert object.string_value? + assert_predicate object, :string_value? object.string_value = " " - assert !object.string_value? + assert_not_predicate object, :string_value? assert_equal 1, object.int_value.to_i - assert object.int_value? + assert_predicate object, :int_value? object.int_value = "0" - assert !object.int_value? + assert_not_predicate object, :int_value? end test "non-attribute read and write" do topic = Topic.new - assert !topic.respond_to?("mumbo") + assert_not_respond_to topic, "mumbo" assert_raise(NoMethodError) { topic.mumbo } assert_raise(NoMethodError) { topic.mumbo = 5 } end test "undeclared attribute method does not affect respond_to? and method_missing" do topic = @target.new(title: "Budget") - assert topic.respond_to?("title") + assert_respond_to topic, "title" assert_equal "Budget", topic.title - assert !topic.respond_to?("title_hello_world") + assert_not_respond_to topic, "title_hello_world" assert_raise(NoMethodError) { topic.title_hello_world } end @@ -494,7 +478,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase @target.attribute_method_prefix prefix meth = "#{prefix}title" - assert topic.respond_to?(meth) + assert_respond_to topic, meth assert_equal ["title"], topic.send(meth) assert_equal ["title", "a"], topic.send(meth, "a") assert_equal ["title", 1, 2, 3], topic.send(meth, 1, 2, 3) @@ -508,7 +492,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase topic = @target.new(title: "Budget") meth = "title#{suffix}" - assert topic.respond_to?(meth) + assert_respond_to topic, meth assert_equal ["title"], topic.send(meth) assert_equal ["title", "a"], topic.send(meth, "a") assert_equal ["title", 1, 2, 3], topic.send(meth, 1, 2, 3) @@ -522,7 +506,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase topic = @target.new(title: "Budget") meth = "#{prefix}title#{suffix}" - assert topic.respond_to?(meth) + assert_respond_to topic, meth assert_equal ["title"], topic.send(meth) assert_equal ["title", "a"], topic.send(meth, "a") assert_equal ["title", 1, 2, 3], topic.send(meth, 1, 2, 3) @@ -544,7 +528,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase else topic = Topic.all.merge!(select: "topics.*, 1=2 as is_test").first end - assert !topic.is_test? + assert_not_predicate topic, :is_test? end test "typecast attribute from select to true" do @@ -555,7 +539,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase else topic = Topic.all.merge!(select: "topics.*, 2=2 as is_test").first end - assert topic.is_test? + assert_predicate topic, :is_test? end test "raises ActiveRecord::DangerousAttributeError when defining an AR method in a model" do @@ -739,6 +723,16 @@ class AttributeMethodsTest < ActiveRecord::TestCase end end + test "setting invalid string to a zone-aware time attribute" do + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + time_string = "ABC" + + record.bonus_time = time_string + assert_nil record.bonus_time + end + end + test "removing time zone-aware types" do with_time_zone_aware_types(:datetime) do in_time_zone "Pacific Time (US & Canada)" do @@ -746,7 +740,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase expected_time = Time.utc(2000, 01, 01, 10) assert_equal expected_time, record.bonus_time - assert record.bonus_time.utc? + assert_predicate record.bonus_time, :utc? end end end @@ -770,7 +764,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase privatize("title") topic = @target.new(title: "The pros and cons of programming naked.") - assert !topic.respond_to?(:title) + assert_not_respond_to topic, :title exception = assert_raise(NoMethodError) { topic.title } assert_includes exception.message, "private method" assert_equal "I'm private", topic.send(:title) @@ -780,7 +774,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase privatize("title=(value)") topic = @target.new - assert !topic.respond_to?(:title=) + assert_not_respond_to topic, :title= exception = assert_raise(NoMethodError) { topic.title = "Pants" } assert_includes exception.message, "private method" topic.send(:title=, "Very large pants") @@ -790,7 +784,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase privatize("title?") topic = @target.new(title: "Isaac Newton's pants") - assert !topic.respond_to?(:title?) + assert_not_respond_to topic, :title? exception = assert_raise(NoMethodError) { topic.title? } assert_includes exception.message, "private method" assert topic.send(:title?) @@ -830,7 +824,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase self.table_name = "computers" end - assert !klass.instance_method_already_implemented?(:system) + assert_not klass.instance_method_already_implemented?(:system) computer = klass.new assert_nil computer.system end @@ -844,8 +838,8 @@ class AttributeMethodsTest < ActiveRecord::TestCase self.table_name = "computers" end - assert !klass.instance_method_already_implemented?(:system) - assert !subklass.instance_method_already_implemented?(:system) + assert_not klass.instance_method_already_implemented?(:system) + assert_not subklass.instance_method_already_implemented?(:system) computer = subklass.new assert_nil computer.system end @@ -982,9 +976,9 @@ class AttributeMethodsTest < ActiveRecord::TestCase test "came_from_user?" do model = @target.first - assert_not model.id_came_from_user? + assert_not_predicate model, :id_came_from_user? model.id = "omg" - assert model.id_came_from_user? + assert_predicate model, :id_came_from_user? end test "accessed_fields" do @@ -997,6 +991,11 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal ["title"], model.accessed_fields end + test "generated attribute methods ancestors have correct class" do + mod = Topic.send(:generated_attribute_methods) + assert_match %r(GeneratedAttributeMethods), mod.inspect + end + private def new_topic_like_ar_class(&block) @@ -1005,7 +1004,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase class_eval(&block) end - assert_empty klass.generated_attribute_methods.instance_methods(false) + assert_empty klass.send(:generated_attribute_methods).instance_methods(false) klass end @@ -1017,14 +1016,6 @@ class AttributeMethodsTest < ActiveRecord::TestCase ActiveRecord::Base.time_zone_aware_types = old_types end - def cached_columns - Topic.columns.map(&:name) - end - - def time_related_columns_on_topic - Topic.columns.select { |c| [:time, :date, :datetime, :timestamp].include?(c.type) } - end - def privatize(method_signature) @target.class_eval(<<-private_method, __FILE__, __LINE__ + 1) private diff --git a/activerecord/test/cases/attribute_set_test.rb b/activerecord/test/cases/attribute_set_test.rb deleted file mode 100644 index bd4b200735..0000000000 --- a/activerecord/test/cases/attribute_set_test.rb +++ /dev/null @@ -1,253 +0,0 @@ -require "cases/helper" - -module ActiveRecord - class AttributeSetTest < ActiveRecord::TestCase - test "building a new set from raw attributes" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal 1, attributes[:foo].value - assert_equal 2.2, attributes[:bar].value - assert_equal :foo, attributes[:foo].name - assert_equal :bar, attributes[:bar].name - end - - test "building with custom types" do - builder = AttributeSet::Builder.new(foo: Type::Float.new) - attributes = builder.build_from_database({ foo: "3.3", bar: "4.4" }, bar: Type::Integer.new) - - assert_equal 3.3, attributes[:foo].value - assert_equal 4, attributes[:bar].value - end - - test "[] returns a null object" do - builder = AttributeSet::Builder.new(foo: Type::Float.new) - attributes = builder.build_from_database(foo: "3.3") - - assert_equal "3.3", attributes[:foo].value_before_type_cast - assert_nil attributes[:bar].value_before_type_cast - assert_equal :bar, attributes[:bar].name - end - - test "duping creates a new hash, but does not dup the attributes" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) - attributes = builder.build_from_database(foo: 1, bar: "foo") - - # Ensure the type cast value is cached - attributes[:foo].value - attributes[:bar].value - - duped = attributes.dup - duped.write_from_database(:foo, 2) - duped[:bar].value << "bar" - - assert_equal 1, attributes[:foo].value - assert_equal 2, duped[:foo].value - assert_equal "foobar", attributes[:bar].value - assert_equal "foobar", duped[:bar].value - end - - test "deep_duping creates a new hash and dups each attribute" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) - attributes = builder.build_from_database(foo: 1, bar: "foo") - - # Ensure the type cast value is cached - attributes[:foo].value - attributes[:bar].value - - duped = attributes.deep_dup - duped.write_from_database(:foo, 2) - duped[:bar].value << "bar" - - assert_equal 1, attributes[:foo].value - assert_equal 2, duped[:foo].value - assert_equal "foo", attributes[:bar].value - assert_equal "foobar", duped[:bar].value - end - - test "freezing cloned set does not freeze original" do - attributes = AttributeSet.new({}) - clone = attributes.clone - - clone.freeze - - assert clone.frozen? - assert_not attributes.frozen? - end - - test "to_hash returns a hash of the type cast values" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal({ foo: 1, bar: 2.2 }, attributes.to_hash) - assert_equal({ foo: 1, bar: 2.2 }, attributes.to_h) - end - - test "to_hash maintains order" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "2.2", bar: "3.3") - - attributes[:bar] - hash = attributes.to_h - - assert_equal [[:foo, 2], [:bar, 3.3]], hash.to_a - end - - test "values_before_type_cast" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal({ foo: "1.1", bar: "2.2" }, attributes.values_before_type_cast) - end - - test "known columns are built with uninitialized attributes" do - attributes = attributes_with_uninitialized_key - assert attributes[:foo].initialized? - assert_not attributes[:bar].initialized? - end - - test "uninitialized attributes are not included in the attributes hash" do - attributes = attributes_with_uninitialized_key - assert_equal({ foo: 1 }, attributes.to_hash) - end - - test "uninitialized attributes are not included in keys" do - attributes = attributes_with_uninitialized_key - assert_equal [:foo], attributes.keys - end - - test "uninitialized attributes return false for key?" do - attributes = attributes_with_uninitialized_key - assert attributes.key?(:foo) - assert_not attributes.key?(:bar) - end - - test "unknown attributes return false for key?" do - attributes = attributes_with_uninitialized_key - assert_not attributes.key?(:wibble) - end - - test "fetch_value returns the value for the given initialized attribute" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal 1, attributes.fetch_value(:foo) - assert_equal 2.2, attributes.fetch_value(:bar) - end - - test "fetch_value returns nil for unknown attributes" do - attributes = attributes_with_uninitialized_key - assert_nil attributes.fetch_value(:wibble) { "hello" } - end - - test "fetch_value returns nil for unknown attributes when types has a default" do - types = Hash.new(Type::Value.new) - builder = AttributeSet::Builder.new(types) - attributes = builder.build_from_database - - assert_nil attributes.fetch_value(:wibble) { "hello" } - end - - test "fetch_value uses the given block for uninitialized attributes" do - attributes = attributes_with_uninitialized_key - value = attributes.fetch_value(:bar) { |n| n.to_s + "!" } - assert_equal "bar!", value - end - - test "fetch_value returns nil for uninitialized attributes if no block is given" do - attributes = attributes_with_uninitialized_key - assert_nil attributes.fetch_value(:bar) - end - - test "the primary_key is always initialized" do - builder = AttributeSet::Builder.new({ foo: Type::Integer.new }, :foo) - attributes = builder.build_from_database - - assert attributes.key?(:foo) - assert_equal [:foo], attributes.keys - assert attributes[:foo].initialized? - end - - class MyType - def cast(value) - return if value.nil? - value + " from user" - end - - def deserialize(value) - return if value.nil? - value + " from database" - end - - def assert_valid_value(*) - end - end - - test "write_from_database sets the attribute with database typecasting" do - builder = AttributeSet::Builder.new(foo: MyType.new) - attributes = builder.build_from_database - - assert_nil attributes.fetch_value(:foo) - - attributes.write_from_database(:foo, "value") - - assert_equal "value from database", attributes.fetch_value(:foo) - end - - test "write_from_user sets the attribute with user typecasting" do - builder = AttributeSet::Builder.new(foo: MyType.new) - attributes = builder.build_from_database - - assert_nil attributes.fetch_value(:foo) - - attributes.write_from_user(:foo, "value") - - assert_equal "value from user", attributes.fetch_value(:foo) - end - - def attributes_with_uninitialized_key - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - builder.build_from_database(foo: "1.1") - end - - test "freezing doesn't prevent the set from materializing" do - builder = AttributeSet::Builder.new(foo: Type::String.new) - attributes = builder.build_from_database(foo: "1") - - attributes.freeze - assert_equal({ foo: "1" }, attributes.to_hash) - end - - test "#accessed_attributes returns only attributes which have been read" do - builder = AttributeSet::Builder.new(foo: Type::Value.new, bar: Type::Value.new) - attributes = builder.build_from_database(foo: "1", bar: "2") - - assert_equal [], attributes.accessed - - attributes.fetch_value(:foo) - - assert_equal [:foo], attributes.accessed - end - - test "#map returns a new attribute set with the changes applied" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) - attributes = builder.build_from_database(foo: "1", bar: "2") - new_attributes = attributes.map do |attr| - attr.with_cast_value(attr.value + 1) - end - - assert_equal 2, new_attributes.fetch_value(:foo) - assert_equal 3, new_attributes.fetch_value(:bar) - end - - test "comparison for equality is correctly implemented" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) - attributes = builder.build_from_database(foo: "1", bar: "2") - attributes2 = builder.build_from_database(foo: "1", bar: "2") - attributes3 = builder.build_from_database(foo: "2", bar: "2") - - assert_equal attributes, attributes2 - assert_not_equal attributes2, attributes3 - end - end -end diff --git a/activerecord/test/cases/attribute_test.rb b/activerecord/test/cases/attribute_test.rb deleted file mode 100644 index 7cf6b498c9..0000000000 --- a/activerecord/test/cases/attribute_test.rb +++ /dev/null @@ -1,253 +0,0 @@ -require "cases/helper" - -module ActiveRecord - class AttributeTest < ActiveRecord::TestCase - setup do - @type = Minitest::Mock.new - end - - teardown do - assert @type.verify - end - - test "from_database + read type casts from database" do - @type.expect(:deserialize, "type cast from database", ["a value"]) - attribute = Attribute.from_database(nil, "a value", @type) - - type_cast_value = attribute.value - - assert_equal "type cast from database", type_cast_value - end - - test "from_user + read type casts from user" do - @type.expect(:cast, "type cast from user", ["a value"]) - attribute = Attribute.from_user(nil, "a value", @type) - - type_cast_value = attribute.value - - assert_equal "type cast from user", type_cast_value - end - - test "reading memoizes the value" do - @type.expect(:deserialize, "from the database", ["whatever"]) - attribute = Attribute.from_database(nil, "whatever", @type) - - type_cast_value = attribute.value - second_read = attribute.value - - assert_equal "from the database", type_cast_value - assert_same type_cast_value, second_read - end - - test "reading memoizes falsy values" do - @type.expect(:deserialize, false, ["whatever"]) - attribute = Attribute.from_database(nil, "whatever", @type) - - attribute.value - attribute.value - end - - test "read_before_typecast returns the given value" do - attribute = Attribute.from_database(nil, "raw value", @type) - - raw_value = attribute.value_before_type_cast - - assert_equal "raw value", raw_value - end - - test "from_database + read_for_database type casts to and from database" do - @type.expect(:deserialize, "read from database", ["whatever"]) - @type.expect(:serialize, "ready for database", ["read from database"]) - attribute = Attribute.from_database(nil, "whatever", @type) - - serialize = attribute.value_for_database - - assert_equal "ready for database", serialize - end - - test "from_user + read_for_database type casts from the user to the database" do - @type.expect(:cast, "read from user", ["whatever"]) - @type.expect(:serialize, "ready for database", ["read from user"]) - attribute = Attribute.from_user(nil, "whatever", @type) - - serialize = attribute.value_for_database - - assert_equal "ready for database", serialize - end - - test "duping dups the value" do - @type.expect(:deserialize, "type cast", ["a value"]) - attribute = Attribute.from_database(nil, "a value", @type) - - value_from_orig = attribute.value - value_from_clone = attribute.dup.value - value_from_orig << " foo" - - assert_equal "type cast foo", value_from_orig - assert_equal "type cast", value_from_clone - end - - test "duping does not dup the value if it is not dupable" do - @type.expect(:deserialize, false, ["a value"]) - attribute = Attribute.from_database(nil, "a value", @type) - - assert_same attribute.value, attribute.dup.value - end - - test "duping does not eagerly type cast if we have not yet type cast" do - attribute = Attribute.from_database(nil, "a value", @type) - attribute.dup - end - - class MyType - def cast(value) - value + " from user" - end - - def deserialize(value) - value + " from database" - end - - def assert_valid_value(*) - end - end - - test "with_value_from_user returns a new attribute with the value from the user" do - old = Attribute.from_database(nil, "old", MyType.new) - new = old.with_value_from_user("new") - - assert_equal "old from database", old.value - assert_equal "new from user", new.value - end - - test "with_value_from_database returns a new attribute with the value from the database" do - old = Attribute.from_user(nil, "old", MyType.new) - new = old.with_value_from_database("new") - - assert_equal "old from user", old.value - assert_equal "new from database", new.value - end - - test "uninitialized attributes yield their name if a block is given to value" do - block = proc { |name| name.to_s + "!" } - foo = Attribute.uninitialized(:foo, nil) - bar = Attribute.uninitialized(:bar, nil) - - assert_equal "foo!", foo.value(&block) - assert_equal "bar!", bar.value(&block) - end - - test "uninitialized attributes have no value" do - assert_nil Attribute.uninitialized(:foo, nil).value - end - - test "attributes equal other attributes with the same constructor arguments" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:foo, 1, Type::Integer.new) - assert_equal first, second - end - - test "attributes do not equal attributes with different names" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:bar, 1, Type::Integer.new) - assert_not_equal first, second - end - - test "attributes do not equal attributes with different types" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:foo, 1, Type::Float.new) - assert_not_equal first, second - end - - test "attributes do not equal attributes with different values" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:foo, 2, Type::Integer.new) - assert_not_equal first, second - end - - test "attributes do not equal attributes of other classes" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_user(:foo, 1, Type::Integer.new) - assert_not_equal first, second - end - - test "an attribute has not been read by default" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - assert_not attribute.has_been_read? - end - - test "an attribute has been read when its value is calculated" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - attribute.value - assert attribute.has_been_read? - end - - test "an attribute is not changed if it hasn't been assigned or mutated" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - - refute attribute.changed? - end - - test "an attribute is changed if it's been assigned a new value" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - changed = attribute.with_value_from_user(2) - - assert changed.changed? - end - - test "an attribute is not changed if it's assigned the same value" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - unchanged = attribute.with_value_from_user(1) - - refute unchanged.changed? - end - - test "an attribute can not be mutated if it has not been read, - and skips expensive calculations" do - type_which_raises_from_all_methods = Object.new - attribute = Attribute.from_database(:foo, "bar", type_which_raises_from_all_methods) - - assert_not attribute.changed_in_place? - end - - test "an attribute is changed if it has been mutated" do - attribute = Attribute.from_database(:foo, "bar", Type::String.new) - attribute.value << "!" - - assert attribute.changed_in_place? - assert attribute.changed? - end - - test "an attribute can forget its changes" do - attribute = Attribute.from_database(:foo, "bar", Type::String.new) - changed = attribute.with_value_from_user("foo") - forgotten = changed.forgetting_assignment - - assert changed.changed? # sanity check - refute forgotten.changed? - end - - test "with_value_from_user validates the value" do - type = Type::Value.new - type.define_singleton_method(:assert_valid_value) do |value| - if value == 1 - raise ArgumentError - end - end - - attribute = Attribute.from_database(:foo, 1, type) - assert_equal 1, attribute.value - assert_equal 2, attribute.with_value_from_user(2).value - assert_raises ArgumentError do - attribute.with_value_from_user(1) - end - end - - test "with_type preserves mutations" do - attribute = Attribute.from_database(:foo, "", Type::Value.new) - attribute.value << "1" - - assert_equal 1, attribute.with_type(Type::Integer.new).value - end - end -end diff --git a/activerecord/test/cases/attributes_test.rb b/activerecord/test/cases/attributes_test.rb index 3705a6be89..3bc56694be 100644 --- a/activerecord/test/cases/attributes_test.rb +++ b/activerecord/test/cases/attributes_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class OverloadedType < ActiveRecord::Base @@ -57,7 +59,7 @@ module ActiveRecord test "nonexistent attribute" do data = OverloadedType.new(non_existent_decimal: 1) - assert_equal BigDecimal.new(1), data.non_existent_decimal + assert_equal BigDecimal(1), data.non_existent_decimal assert_raise ActiveRecord::UnknownAttributeError do UnoverloadedType.new(non_existent_decimal: 1) end @@ -106,12 +108,14 @@ module ActiveRecord assert_equal 6, klass.attribute_types.length assert_equal 6, klass.column_defaults.length + assert_equal 6, klass.attribute_names.length assert_not klass.attribute_types.include?("wibble") klass.attribute :wibble, Type::Value.new assert_equal 7, klass.attribute_types.length assert_equal 7, klass.column_defaults.length + assert_equal 7, klass.attribute_names.length assert_includes klass.attribute_types, "wibble" end @@ -207,7 +211,7 @@ module ActiveRecord end test "attributes not backed by database columns are not dirty when unchanged" do - refute OverloadedType.new.non_existent_decimal_changed? + assert_not_predicate OverloadedType.new, :non_existent_decimal_changed? end test "attributes not backed by database columns are always initialized" do @@ -241,13 +245,13 @@ module ActiveRecord model.foo << "asdf" assert_equal "lolasdf", model.foo - assert model.foo_changed? + assert_predicate model, :foo_changed? model.reload assert_equal "lol", model.foo model.foo = "lol" - refute model.changed? + assert_not_predicate model, :changed? end test "attributes not backed by database columns appear in inspect" do diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index 2203aa1788..fa618735d7 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + require "cases/helper" require "models/bird" require "models/post" require "models/comment" require "models/company" +require "models/contract" require "models/customer" require "models/developer" require "models/computer" @@ -10,9 +13,7 @@ require "models/invoice" require "models/line_item" require "models/order" require "models/parrot" -require "models/person" require "models/pirate" -require "models/reader" require "models/ship" require "models/ship_part" require "models/tag" @@ -26,6 +27,7 @@ require "models/member_detail" require "models/organization" require "models/guitar" require "models/tuning_peg" +require "models/reply" class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase def test_autosave_validation @@ -49,8 +51,8 @@ class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase } u = person.create!(first_name: "cool") - u.update_attributes!(first_name: "nah") # still valid because validation only applies on 'create' - assert reference.create!(person: u).persisted? + u.update!(first_name: "nah") # still valid because validation only applies on 'create' + assert_predicate reference.create!(person: u), :persisted? end def test_should_not_add_the_same_callbacks_multiple_times_for_has_one @@ -73,7 +75,7 @@ class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase ship = ShipWithoutNestedAttributes.new ship.prisoners.build - assert_not ship.valid? + assert_not_predicate ship, :valid? assert_equal 1, ship.errors[:name].length end @@ -98,35 +100,35 @@ class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCas def test_should_save_parent_but_not_invalid_child firm = Firm.new(name: "GlobalMegaCorp") - assert firm.valid? + assert_predicate firm, :valid? firm.build_account_using_primary_key - assert !firm.build_account_using_primary_key.valid? + assert_not_predicate firm.build_account_using_primary_key, :valid? assert firm.save - assert !firm.account_using_primary_key.persisted? + assert_not_predicate firm.account_using_primary_key, :persisted? end def test_save_fails_for_invalid_has_one firm = Firm.first - assert firm.valid? + assert_predicate firm, :valid? firm.build_account - assert !firm.account.valid? - assert !firm.valid? - assert !firm.save + assert_not_predicate firm.account, :valid? + assert_not_predicate firm, :valid? + assert_not firm.save assert_equal ["is invalid"], firm.errors["account"] end def test_save_succeeds_for_invalid_has_one_with_validate_false firm = Firm.first - assert firm.valid? + assert_predicate firm, :valid? firm.build_unvalidated_account - assert !firm.unvalidated_account.valid? - assert firm.valid? + assert_not_predicate firm.unvalidated_account, :valid? + assert_predicate firm, :valid? assert firm.save end @@ -135,10 +137,10 @@ class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCas account = firm.build_account("credit_limit" => 1000) assert_equal account, firm.account - assert !account.persisted? + assert_not_predicate account, :persisted? assert firm.save assert_equal account, firm.account - assert account.persisted? + assert_predicate account, :persisted? end def test_build_before_either_saved @@ -146,16 +148,16 @@ class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCas firm.account = account = Account.new("credit_limit" => 1000) assert_equal account, firm.account - assert !account.persisted? + assert_not_predicate account, :persisted? assert firm.save assert_equal account, firm.account - assert account.persisted? + assert_predicate account, :persisted? end def test_assignment_before_parent_saved firm = Firm.new("name" => "GlobalMegaCorp") firm.account = a = Account.find(1) - assert !firm.persisted? + assert_not_predicate firm, :persisted? assert_equal a, firm.account assert firm.save assert_equal a, firm.account @@ -166,12 +168,12 @@ class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCas def test_assignment_before_either_saved firm = Firm.new("name" => "GlobalMegaCorp") firm.account = a = Account.new("credit_limit" => 1000) - assert !firm.persisted? - assert !a.persisted? + assert_not_predicate firm, :persisted? + assert_not_predicate a, :persisted? assert_equal a, firm.account assert firm.save - assert firm.persisted? - assert a.persisted? + assert_predicate firm, :persisted? + assert_predicate a, :persisted? assert_equal a, firm.account firm.association(:account).reload assert_equal a, firm.account @@ -220,13 +222,13 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test def test_should_save_parent_but_not_invalid_child client = Client.new(name: "Joe (the Plumber)") - assert client.valid? + assert_predicate client, :valid? client.build_firm - assert !client.firm.valid? + assert_not_predicate client.firm, :valid? assert client.save - assert !client.firm.persisted? + assert_not_predicate client.firm, :persisted? end def test_save_fails_for_invalid_belongs_to @@ -234,9 +236,9 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test assert log = AuditLog.create(developer_id: 0, message: " ") log.developer = Developer.new - assert !log.developer.valid? - assert !log.valid? - assert !log.save + assert_not_predicate log.developer, :valid? + assert_not_predicate log, :valid? + assert_not log.save assert_equal ["is invalid"], log.errors["developer"] end @@ -245,8 +247,8 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test assert log = AuditLog.create(developer_id: 0, message: " ") log.unvalidated_developer = Developer.new - assert !log.unvalidated_developer.valid? - assert log.valid? + assert_not_predicate log.unvalidated_developer, :valid? + assert_predicate log, :valid? assert log.save end @@ -255,10 +257,10 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test apple = Firm.new("name" => "Apple") client.firm = apple assert_equal apple, client.firm - assert !apple.persisted? + assert_not_predicate apple, :persisted? assert client.save assert apple.save - assert apple.persisted? + assert_predicate apple, :persisted? assert_equal apple, client.firm client.association(:firm).reload assert_equal apple, client.firm @@ -268,11 +270,11 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test final_cut = Client.new("name" => "Final Cut") apple = Firm.new("name" => "Apple") final_cut.firm = apple - assert !final_cut.persisted? - assert !apple.persisted? + assert_not_predicate final_cut, :persisted? + assert_not_predicate apple, :persisted? assert final_cut.save - assert final_cut.persisted? - assert apple.persisted? + assert_predicate final_cut, :persisted? + assert_predicate apple, :persisted? assert_equal apple, final_cut.firm final_cut.association(:firm).reload assert_equal apple, final_cut.firm @@ -381,7 +383,7 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test auditlog.developer = invalid_developer auditlog.developer_id = valid_developer.id - assert auditlog.valid? + assert_predicate auditlog, :valid? end end @@ -394,8 +396,8 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib molecule.electrons = [valid_electron, invalid_electron] molecule.save - assert_not invalid_electron.valid? - assert valid_electron.valid? + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? assert_not molecule.persisted?, "Molecule should not be persisted when its electrons are invalid" end @@ -407,9 +409,9 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib guitar.tuning_pegs = [tuning_peg_valid, tuning_peg_invalid] - assert_not tuning_peg_invalid.valid? - assert tuning_peg_valid.valid? - assert_not guitar.valid? + assert_not_predicate tuning_peg_invalid, :valid? + assert_predicate tuning_peg_valid, :valid? + assert_not_predicate guitar, :valid? assert_equal ["is not a number"], guitar.errors["tuning_pegs[1].pitch"] assert_not_equal ["is not a number"], guitar.errors["tuning_pegs.pitch"] end @@ -424,9 +426,9 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib molecule.electrons = [valid_electron, invalid_electron] - assert_not invalid_electron.valid? - assert valid_electron.valid? - assert_not molecule.valid? + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not_predicate molecule, :valid? assert_equal ["can't be blank"], molecule.errors["electrons[1].name"] assert_not_equal ["can't be blank"], molecule.errors["electrons.name"] ensure @@ -440,9 +442,9 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib molecule.electrons = [valid_electron, invalid_electron] - assert_not invalid_electron.valid? - assert valid_electron.valid? - assert_not molecule.valid? + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not_predicate molecule, :valid? assert_equal [{ error: :blank }], molecule.errors.details[:"electrons.name"] end @@ -454,9 +456,9 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib guitar.tuning_pegs = [tuning_peg_valid, tuning_peg_invalid] - assert_not tuning_peg_invalid.valid? - assert tuning_peg_valid.valid? - assert_not guitar.valid? + assert_not_predicate tuning_peg_invalid, :valid? + assert_predicate tuning_peg_valid, :valid? + assert_not_predicate guitar, :valid? assert_equal [{ error: :not_a_number, value: nil }], guitar.errors.details[:"tuning_pegs[1].pitch"] assert_equal [], guitar.errors.details[:"tuning_pegs.pitch"] end @@ -471,9 +473,9 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib molecule.electrons = [valid_electron, invalid_electron] - assert_not invalid_electron.valid? - assert valid_electron.valid? - assert_not molecule.valid? + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not_predicate molecule, :valid? assert_equal [{ error: :blank }], molecule.errors.details[:"electrons[1].name"] assert_equal [], molecule.errors.details[:"electrons.name"] ensure @@ -487,33 +489,45 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttrib molecule.electrons = [valid_electron] molecule.save - assert valid_electron.valid? - assert molecule.persisted? + assert_predicate valid_electron, :valid? + assert_predicate molecule, :persisted? assert_equal 1, molecule.electrons.count end end class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase - fixtures :companies, :people + fixtures :companies, :developers def test_invalid_adding firm = Firm.find(1) - assert !(firm.clients_of_firm << c = Client.new) - assert !c.persisted? - assert !firm.valid? - assert !firm.save - assert !c.persisted? + assert_not (firm.clients_of_firm << c = Client.new) + assert_not_predicate c, :persisted? + assert_not_predicate firm, :valid? + assert_not firm.save + assert_not_predicate c, :persisted? end def test_invalid_adding_before_save new_firm = Firm.new("name" => "A New Firm, Inc") new_firm.clients_of_firm.concat([c = Client.new, Client.new("name" => "Apple")]) - assert !c.persisted? - assert !c.valid? - assert !new_firm.valid? - assert !new_firm.save - assert !c.persisted? - assert !new_firm.persisted? + assert_not_predicate c, :persisted? + assert_not_predicate c, :valid? + assert_not_predicate new_firm, :valid? + assert_not new_firm.save + assert_not_predicate c, :persisted? + assert_not_predicate new_firm, :persisted? + end + + def test_adding_unsavable_association + new_firm = Firm.new("name" => "A New Firm, Inc") + client = new_firm.clients.new("name" => "Apple") + client.throw_on_save = true + + assert_predicate client, :valid? + assert_predicate new_firm, :valid? + assert_not new_firm.save + assert_not_predicate new_firm, :persisted? + assert_not_predicate client, :persisted? end def test_invalid_adding_with_validate_false @@ -521,10 +535,10 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa client = Client.new firm.unvalidated_clients_of_firm << client - assert firm.valid? - assert !client.valid? + assert_predicate firm, :valid? + assert_not_predicate client, :valid? assert firm.save - assert !client.persisted? + assert_not_predicate client, :persisted? end def test_valid_adding_with_validate_false @@ -533,24 +547,52 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa firm = Firm.first client = Client.new("name" => "Apple") - assert firm.valid? - assert client.valid? - assert !client.persisted? + assert_predicate firm, :valid? + assert_predicate client, :valid? + assert_not_predicate client, :persisted? firm.unvalidated_clients_of_firm << client assert firm.save - assert client.persisted? + assert_predicate client, :persisted? assert_equal no_of_clients + 1, Client.count end + def test_parent_should_save_children_record_with_foreign_key_validation_set_in_before_save_callback + company = NewlyContractedCompany.new(name: "test") + + assert company.save + assert_not_empty company.reload.new_contracts + end + + def test_parent_should_not_get_saved_with_duplicate_children_records + assert_no_difference "Reply.count" do + assert_no_difference "SillyUniqueReply.count" do + reply = Reply.new + reply.silly_unique_replies.build([ + { content: "Best content" }, + { content: "Best content" } + ]) + + assert_not reply.save + assert_equal ["is invalid"], reply.errors[:silly_unique_replies] + assert_empty reply.silly_unique_replies.first.errors + + assert_equal( + ["has already been taken"], + reply.silly_unique_replies.last.errors[:content] + ) + end + end + end + def test_invalid_build new_client = companies(:first_firm).clients_of_firm.build - assert !new_client.persisted? - assert !new_client.valid? + assert_not_predicate new_client, :persisted? + assert_not_predicate new_client, :valid? assert_equal new_client, companies(:first_firm).clients_of_firm.last - assert !companies(:first_firm).save - assert !new_client.persisted? + assert_not companies(:first_firm).save + assert_not_predicate new_client, :persisted? assert_equal 2, companies(:first_firm).clients_of_firm.reload.size end @@ -569,8 +611,8 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa assert_equal no_of_firms, Firm.count # Firm was not saved to database. assert_equal no_of_clients, Client.count # Clients were not saved to database. assert new_firm.save - assert new_firm.persisted? - assert c.persisted? + assert_predicate new_firm, :persisted? + assert_predicate c, :persisted? assert_equal new_firm, c.firm assert_equal no_of_firms + 1, Firm.count # Firm was saved to database. assert_equal no_of_clients + 2, Client.count # Clients were saved to database. @@ -589,22 +631,22 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa end def test_assign_ids_for_through_a_belongs_to - post = Post.new(title: "Assigning IDs works!", body: "You heard it here first, folks!") - post.person_ids = [people(:david).id, people(:michael).id] - post.save - post.reload - assert_equal 2, post.people.length - assert_includes post.people, people(:david) + firm = Firm.new("name" => "Apple") + firm.developer_ids = [developers(:david).id, developers(:jamis).id] + firm.save + firm.reload + assert_equal 2, firm.developers.length + assert_includes firm.developers, developers(:david) end def test_build_before_save company = companies(:first_firm) new_client = assert_no_queries(ignore_none: false) { company.clients_of_firm.build("name" => "Another Client") } - assert !company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? company.name += "-changed" assert_queries(2) { assert company.save } - assert new_client.persisted? + assert_predicate new_client, :persisted? assert_equal 3, company.clients_of_firm.reload.size end @@ -620,11 +662,11 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa def test_build_via_block_before_save company = companies(:first_firm) new_client = assert_no_queries(ignore_none: false) { company.clients_of_firm.build { |client| client.name = "Another Client" } } - assert !company.clients_of_firm.loaded? + assert_not_predicate company.clients_of_firm, :loaded? company.name += "-changed" assert_queries(2) { assert company.save } - assert new_client.persisted? + assert_predicate new_client, :persisted? assert_equal 3, company.clients_of_firm.reload.size end @@ -656,62 +698,62 @@ class TestDefaultAutosaveAssociationOnNewRecord < ActiveRecord::TestCase new_account = Account.new("credit_limit" => 1000) new_firm = Firm.new("name" => "some firm") - assert !new_firm.persisted? + assert_not_predicate new_firm, :persisted? new_account.firm = new_firm new_account.save! - assert new_firm.persisted? + assert_predicate new_firm, :persisted? new_account = Account.new("credit_limit" => 1000) new_autosaved_firm = Firm.new("name" => "some firm") - assert !new_autosaved_firm.persisted? + assert_not_predicate new_autosaved_firm, :persisted? new_account.unautosaved_firm = new_autosaved_firm new_account.save! - assert !new_autosaved_firm.persisted? + assert_not_predicate new_autosaved_firm, :persisted? end def test_autosave_new_record_on_has_one_can_be_disabled_per_relationship firm = Firm.new("name" => "some firm") account = Account.new("credit_limit" => 1000) - assert !account.persisted? + assert_not_predicate account, :persisted? firm.account = account firm.save! - assert account.persisted? + assert_predicate account, :persisted? firm = Firm.new("name" => "some firm") account = Account.new("credit_limit" => 1000) firm.unautosaved_account = account - assert !account.persisted? + assert_not_predicate account, :persisted? firm.unautosaved_account = account firm.save! - assert !account.persisted? + assert_not_predicate account, :persisted? end def test_autosave_new_record_on_has_many_can_be_disabled_per_relationship firm = Firm.new("name" => "some firm") account = Account.new("credit_limit" => 1000) - assert !account.persisted? + assert_not_predicate account, :persisted? firm.accounts << account firm.save! - assert account.persisted? + assert_predicate account, :persisted? firm = Firm.new("name" => "some firm") account = Account.new("credit_limit" => 1000) - assert !account.persisted? + assert_not_predicate account, :persisted? firm.unautosaved_accounts << account firm.save! - assert !account.persisted? + assert_not_predicate account, :persisted? end def test_autosave_new_record_with_after_create_callback @@ -744,18 +786,18 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @pirate.mark_for_destruction @pirate.ship.mark_for_destruction - assert !@pirate.reload.marked_for_destruction? - assert !@pirate.ship.reload.marked_for_destruction? + assert_not_predicate @pirate.reload, :marked_for_destruction? + assert_not_predicate @pirate.ship.reload, :marked_for_destruction? end # has_one def test_should_destroy_a_child_association_as_part_of_the_save_transaction_if_it_was_marked_for_destruction - assert !@pirate.ship.marked_for_destruction? + assert_not_predicate @pirate.ship, :marked_for_destruction? @pirate.ship.mark_for_destruction id = @pirate.ship.id - assert @pirate.ship.marked_for_destruction? + assert_predicate @pirate.ship, :marked_for_destruction? assert Ship.find_by_id(id) @pirate.save @@ -765,11 +807,12 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase def test_should_skip_validation_on_a_child_association_if_marked_for_destruction @pirate.ship.name = "" - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? @pirate.ship.mark_for_destruction - @pirate.ship.expects(:valid?).never - assert_difference("Ship.count", -1) { @pirate.save! } + assert_not_called(@pirate.ship, :valid?) do + assert_difference("Ship.count", -1) { @pirate.save! } + end end def test_a_child_marked_for_destruction_should_not_be_destroyed_twice @@ -794,7 +837,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @ship.pirate.catchphrase = "Changed Catchphrase" @ship.name_will_change! - assert_raise(RuntimeError) { assert !@pirate.save } + assert_raise(RuntimeError) { assert_not @pirate.save } assert_not_nil @pirate.reload.ship end @@ -805,18 +848,19 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase end def test_should_not_save_changed_has_one_unchanged_object_if_child_is_saved - @pirate.ship.expects(:save).never - assert @pirate.save + assert_not_called(@pirate.ship, :save) do + assert @pirate.save + end end # belongs_to def test_should_destroy_a_parent_association_as_part_of_the_save_transaction_if_it_was_marked_for_destruction - assert !@ship.pirate.marked_for_destruction? + assert_not_predicate @ship.pirate, :marked_for_destruction? @ship.pirate.mark_for_destruction id = @ship.pirate.id - assert @ship.pirate.marked_for_destruction? + assert_predicate @ship.pirate, :marked_for_destruction? assert Pirate.find_by_id(id) @ship.save @@ -826,11 +870,12 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase def test_should_skip_validation_on_a_parent_association_if_marked_for_destruction @ship.pirate.catchphrase = "" - assert !@ship.valid? + assert_not_predicate @ship, :valid? @ship.pirate.mark_for_destruction - @ship.pirate.expects(:valid?).never - assert_difference("Pirate.count", -1) { @ship.save! } + assert_not_called(@ship.pirate, :valid?) do + assert_difference("Pirate.count", -1) { @ship.save! } + end end def test_a_parent_marked_for_destruction_should_not_be_destroyed_twice @@ -854,7 +899,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @ship.pirate.catchphrase = "Changed Catchphrase" - assert_raise(RuntimeError) { assert !@ship.save } + assert_raise(RuntimeError) { assert_not @ship.save } assert_not_nil @ship.reload.pirate end @@ -870,7 +915,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase def test_should_destroy_has_many_as_part_of_the_save_transaction_if_they_were_marked_for_destruction 2.times { |i| @pirate.birds.create!(name: "birds_#{i}") } - assert !@pirate.birds.any?(&:marked_for_destruction?) + assert_not @pirate.birds.any?(&:marked_for_destruction?) @pirate.birds.each(&:mark_for_destruction) klass = @pirate.birds.first.class @@ -880,7 +925,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase ids.each { |id| assert klass.find_by_id(id) } @pirate.save - assert @pirate.reload.birds.empty? + assert_empty @pirate.reload.birds ids.each { |id| assert_nil klass.find_by_id(id) } end @@ -888,30 +933,32 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @pirate.birds.create!(name: :parrot) @pirate.birds.first.destroy @pirate.save! - assert @pirate.reload.birds.empty? + assert_empty @pirate.reload.birds end def test_should_skip_validation_on_has_many_if_marked_for_destruction 2.times { |i| @pirate.birds.create!(name: "birds_#{i}") } @pirate.birds.each { |bird| bird.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? - @pirate.birds.each do |bird| - bird.mark_for_destruction - bird.expects(:valid?).never + @pirate.birds.each(&:mark_for_destruction) + + assert_not_called(@pirate.birds.first, :valid?) do + assert_not_called(@pirate.birds.last, :valid?) do + assert_difference("Bird.count", -2) { @pirate.save! } + end end - assert_difference("Bird.count", -2) { @pirate.save! } end def test_should_skip_validation_on_has_many_if_destroyed @pirate.birds.create!(name: "birds_1") @pirate.birds.each { |bird| bird.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? @pirate.birds.each(&:destroy) - assert @pirate.valid? + assert_predicate @pirate, :valid? end def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_has_many @@ -920,8 +967,11 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @pirate.birds.each(&:mark_for_destruction) assert @pirate.save - @pirate.birds.each { |bird| bird.expects(:destroy).never } - assert @pirate.save + @pirate.birds.each do |bird| + assert_not_called(bird, :destroy) do + assert @pirate.save + end + end end def test_should_rollback_destructions_if_an_exception_occurred_while_saving_has_many @@ -936,7 +986,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase end end - assert_raise(RuntimeError) { assert !@pirate.save } + assert_raise(RuntimeError) { assert_not @pirate.save } assert_equal before, @pirate.reload.birds end @@ -1002,42 +1052,44 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase def test_should_destroy_habtm_as_part_of_the_save_transaction_if_they_were_marked_for_destruction 2.times { |i| @pirate.parrots.create!(name: "parrots_#{i}") } - assert !@pirate.parrots.any?(&:marked_for_destruction?) + assert_not @pirate.parrots.any?(&:marked_for_destruction?) @pirate.parrots.each(&:mark_for_destruction) assert_no_difference "Parrot.count" do @pirate.save end - assert @pirate.reload.parrots.empty? + assert_empty @pirate.reload.parrots join_records = Pirate.connection.select_all("SELECT * FROM parrots_pirates WHERE pirate_id = #{@pirate.id}") - assert join_records.empty? + assert_empty join_records end def test_should_skip_validation_on_habtm_if_marked_for_destruction 2.times { |i| @pirate.parrots.create!(name: "parrots_#{i}") } @pirate.parrots.each { |parrot| parrot.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? - @pirate.parrots.each do |parrot| - parrot.mark_for_destruction - parrot.expects(:valid?).never + @pirate.parrots.each { |parrot| parrot.mark_for_destruction } + + assert_not_called(@pirate.parrots.first, :valid?) do + assert_not_called(@pirate.parrots.last, :valid?) do + @pirate.save! + end end - @pirate.save! - assert @pirate.reload.parrots.empty? + assert_empty @pirate.reload.parrots end def test_should_skip_validation_on_habtm_if_destroyed @pirate.parrots.create!(name: "parrots_1") @pirate.parrots.each { |parrot| parrot.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? @pirate.parrots.each(&:destroy) - assert @pirate.valid? + assert_predicate @pirate, :valid? end def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_habtm @@ -1064,7 +1116,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase end end - assert_raise(RuntimeError) { assert !@pirate.save } + assert_raise(RuntimeError) { assert_not @pirate.save } assert_equal before, @pirate.reload.parrots end @@ -1144,16 +1196,16 @@ class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase def test_should_automatically_validate_the_associated_model @pirate.ship.name = "" - assert @pirate.invalid? - assert @pirate.errors[:"ship.name"].any? + assert_predicate @pirate, :invalid? + assert_predicate @pirate.errors[:"ship.name"], :any? end def test_should_merge_errors_on_the_associated_models_onto_the_parent_even_if_it_is_not_valid @pirate.ship.name = nil @pirate.catchphrase = nil - assert @pirate.invalid? - assert @pirate.errors[:"ship.name"].any? - assert @pirate.errors[:catchphrase].any? + assert_predicate @pirate, :invalid? + assert_predicate @pirate.errors[:"ship.name"], :any? + assert_predicate @pirate.errors[:catchphrase], :any? end def test_should_not_ignore_different_error_messages_on_the_same_attribute @@ -1162,7 +1214,7 @@ class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase Ship.validates_format_of :name, with: /\w/ @pirate.ship.name = "" @pirate.catchphrase = nil - assert @pirate.invalid? + assert_predicate @pirate, :invalid? assert_equal ["can't be blank", "is invalid"], @pirate.errors[:"ship.name"] ensure Ship._validators = old_validators if old_validators @@ -1212,7 +1264,7 @@ class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase assert_no_difference "Pirate.count" do assert_no_difference "Ship.count" do - assert !pirate.save + assert_not pirate.save end end end @@ -1231,7 +1283,7 @@ class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase end end - assert_raise(RuntimeError) { assert !@pirate.save } + assert_raise(RuntimeError) { assert_not @pirate.save } assert_equal before, [@pirate.reload.catchphrase, @pirate.ship.name] end @@ -1243,7 +1295,7 @@ class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase ship = ShipWithoutNestedAttributes.new(name: "The Black Flag") ship.parts.build.mark_for_destruction - assert_not ship.valid? + assert_not_predicate ship, :valid? end end @@ -1298,16 +1350,16 @@ class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase def test_should_automatically_validate_the_associated_model @ship.pirate.catchphrase = "" - assert @ship.invalid? - assert @ship.errors[:"pirate.catchphrase"].any? + assert_predicate @ship, :invalid? + assert_predicate @ship.errors[:"pirate.catchphrase"], :any? end def test_should_merge_errors_on_the_associated_model_onto_the_parent_even_if_it_is_not_valid @ship.name = nil @ship.pirate.catchphrase = nil - assert @ship.invalid? - assert @ship.errors[:name].any? - assert @ship.errors[:"pirate.catchphrase"].any? + assert_predicate @ship, :invalid? + assert_predicate @ship.errors[:name], :any? + assert_predicate @ship.errors[:"pirate.catchphrase"], :any? end def test_should_still_allow_to_bypass_validations_on_the_associated_model @@ -1336,7 +1388,7 @@ class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase assert_no_difference "Ship.count" do assert_no_difference "Pirate.count" do - assert !ship.save + assert_not ship.save end end end @@ -1355,7 +1407,7 @@ class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase end end - assert_raise(RuntimeError) { assert !@ship.save } + assert_raise(RuntimeError) { assert_not @ship.save } assert_equal before, [@ship.pirate.reload.catchphrase, @ship.reload.name] end @@ -1370,7 +1422,7 @@ module AutosaveAssociationOnACollectionAssociationTests @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] } @pirate.save - assert_equal new_names, @pirate.reload.send(@association_name).map(&:name) + assert_equal new_names.sort, @pirate.reload.send(@association_name).map(&:name).sort end def test_should_automatically_save_bang_the_associated_models @@ -1378,7 +1430,7 @@ module AutosaveAssociationOnACollectionAssociationTests @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] } @pirate.save! - assert_equal new_names, @pirate.reload.send(@association_name).map(&:name) + assert_equal new_names.sort, @pirate.reload.send(@association_name).map(&:name).sort end def test_should_update_children_when_autosave_is_true_and_parent_is_new_but_child_is_not @@ -1402,17 +1454,17 @@ module AutosaveAssociationOnACollectionAssociationTests def test_should_automatically_validate_the_associated_models @pirate.send(@association_name).each { |child| child.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] - assert @pirate.errors[@association_name].empty? + assert_empty @pirate.errors[@association_name] end def test_should_not_use_default_invalid_error_on_associated_models @pirate.send(@association_name).build(name: "") - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] - assert @pirate.errors[@association_name].empty? + assert_empty @pirate.errors[@association_name] end def test_should_default_invalid_error_from_i18n @@ -1422,10 +1474,10 @@ module AutosaveAssociationOnACollectionAssociationTests @pirate.send(@association_name).build(name: "") - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? assert_equal ["cannot be blank"], @pirate.errors["#{@association_name}.name"] assert_equal ["#{@association_name.to_s.humanize} name cannot be blank"], @pirate.errors.full_messages - assert @pirate.errors[@association_name].empty? + assert_empty @pirate.errors[@association_name] ensure I18n.backend = I18n::Backend::Simple.new end @@ -1434,9 +1486,9 @@ module AutosaveAssociationOnACollectionAssociationTests @pirate.send(@association_name).each { |child| child.name = "" } @pirate.catchphrase = nil - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] - assert @pirate.errors[:catchphrase].any? + assert_predicate @pirate.errors[:catchphrase], :any? end def test_should_allow_to_bypass_validations_on_the_associated_models_on_update @@ -1479,7 +1531,7 @@ module AutosaveAssociationOnACollectionAssociationTests @child_1.name = "Changed" @child_1.cancel_save_from_callback = true - assert !@pirate.save + assert_not @pirate.save assert_equal "Don' botharrr talkin' like one, savvy?", @pirate.reload.catchphrase assert_equal "Posideons Killer", @child_1.reload.name @@ -1489,7 +1541,7 @@ module AutosaveAssociationOnACollectionAssociationTests assert_no_difference "Pirate.count" do assert_no_difference "#{new_child.class.name}.count" do - assert !new_pirate.save + assert_not new_pirate.save end end end @@ -1509,7 +1561,7 @@ module AutosaveAssociationOnACollectionAssociationTests end end - assert_raise(RuntimeError) { assert !@pirate.save } + assert_raise(RuntimeError) { assert_not @pirate.save } assert_equal before, [@pirate.reload.catchphrase, *@pirate.send(@association_name).map(&:name)] end @@ -1594,10 +1646,10 @@ class TestAutosaveAssociationValidationsOnAHasManyAssociation < ActiveRecord::Te end test "should automatically validate associations" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.birds.each { |bird| bird.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? end end @@ -1612,15 +1664,15 @@ class TestAutosaveAssociationValidationsOnAHasOneAssociation < ActiveRecord::Tes end test "should automatically validate associations with :validate => true" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.ship.name = "" - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? end test "should not automatically add validate associations without :validate => true" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.non_validated_ship.name = "" - assert @pirate.valid? + assert_predicate @pirate, :valid? end end @@ -1633,15 +1685,15 @@ class TestAutosaveAssociationValidationsOnABelongsToAssociation < ActiveRecord:: end test "should automatically validate associations with :validate => true" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.parrot = Parrot.new(name: "") - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? end test "should not automatically validate associations without :validate => true" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.non_validated_parrot = Parrot.new(name: "") - assert @pirate.valid? + assert_predicate @pirate, :valid? end end @@ -1654,17 +1706,17 @@ class TestAutosaveAssociationValidationsOnAHABTMAssociation < ActiveRecord::Test end test "should automatically validate associations with :validate => true" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.parrots = [ Parrot.new(name: "popuga") ] @pirate.parrots.each { |parrot| parrot.name = "" } - assert !@pirate.valid? + assert_not_predicate @pirate, :valid? end test "should not automatically validate associations without :validate => true" do - assert @pirate.valid? + assert_predicate @pirate, :valid? @pirate.non_validated_parrots = [ Parrot.new(name: "popuga") ] @pirate.non_validated_parrots.each { |parrot| parrot.name = "" } - assert @pirate.valid? + assert_predicate @pirate, :valid? end end @@ -1685,7 +1737,7 @@ class TestAutosaveAssociationValidationMethodsGeneration < ActiveRecord::TestCas end test "should not generate validation methods for has_one associations without :validate => true" do - assert !@pirate.respond_to?(:validate_associated_records_for_non_validated_ship) + assert_not_respond_to @pirate, :validate_associated_records_for_non_validated_ship end test "should generate validation methods for belongs_to associations with :validate => true" do @@ -1693,7 +1745,7 @@ class TestAutosaveAssociationValidationMethodsGeneration < ActiveRecord::TestCas end test "should not generate validation methods for belongs_to associations without :validate => true" do - assert !@pirate.respond_to?(:validate_associated_records_for_non_validated_parrot) + assert_not_respond_to @pirate, :validate_associated_records_for_non_validated_parrot end test "should generate validation methods for HABTM associations with :validate => true" do @@ -1722,6 +1774,10 @@ class TestAutosaveAssociationOnAHasManyAssociationWithInverse < ActiveRecord::Te end end + def setup + Comment.delete_all + end + def test_after_save_callback_with_autosave post = Post.new(title: "Test", body: "...") comment = post.comments.build(body: "...") diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index 979a59f566..f6311f9256 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/author" @@ -12,7 +14,6 @@ require "models/computer" require "models/project" require "models/default" require "models/auto_id" -require "models/boolean" require "models/column_name" require "models/subscriber" require "models/comment" @@ -64,7 +65,7 @@ class LintTest < ActiveRecord::TestCase end class BasicsTest < ActiveRecord::TestCase - fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, "warehouse-things", :authors, :categorizations, :categories, :posts + fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, "warehouse-things", :authors, :author_addresses, :categorizations, :categories, :posts def test_column_names_are_escaped conn = ActiveRecord::Base.connection @@ -102,7 +103,7 @@ class BasicsTest < ActiveRecord::TestCase pk = Author.columns_hash["id"] ref = Post.columns_hash["author_id"] - assert_equal pk.bigint?, ref.bigint? + assert_equal pk.sql_type, ref.sql_type end def test_many_mutations @@ -145,8 +146,8 @@ class BasicsTest < ActiveRecord::TestCase end def test_table_exists - assert !NonExistentTable.table_exists? - assert Topic.table_exists? + assert_not_predicate NonExistentTable, :table_exists? + assert_predicate Topic, :table_exists? end def test_preserving_date_objects @@ -305,7 +306,7 @@ class BasicsTest < ActiveRecord::TestCase assert_equal "Dude", cbs[0].name assert_equal "Bob", cbs[1].name assert cbs[0].frickinawesome - assert !cbs[1].frickinawesome + assert_not cbs[1].frickinawesome end def test_load @@ -448,7 +449,7 @@ class BasicsTest < ActiveRecord::TestCase def test_default_values topic = Topic.new - assert topic.approved? + assert_predicate topic, :approved? assert_nil topic.written_on assert_nil topic.bonus_time assert_nil topic.last_read @@ -456,7 +457,7 @@ class BasicsTest < ActiveRecord::TestCase topic.save topic = Topic.find(topic.id) - assert topic.approved? + assert_predicate topic, :approved? assert_nil topic.last_read # Oracle has some funky default handling, so it requires a bit of @@ -627,7 +628,7 @@ class BasicsTest < ActiveRecord::TestCase end def test_readonly_attributes - assert_equal Set.new([ "title" , "comments_count" ]), ReadonlyTitlePost.readonly_attributes + assert_equal Set.new([ "title", "comments_count" ]), ReadonlyTitlePost.readonly_attributes post = ReadonlyTitlePost.create(title: "cannot change this", body: "changeable") post.reload @@ -703,46 +704,15 @@ class BasicsTest < ActiveRecord::TestCase assert_nil topic.bonus_time end - def test_boolean - b_nil = Boolean.create("value" => nil) - nil_id = b_nil.id - b_false = Boolean.create("value" => false) - false_id = b_false.id - b_true = Boolean.create("value" => true) - true_id = b_true.id - - b_nil = Boolean.find(nil_id) - assert_nil b_nil.value - b_false = Boolean.find(false_id) - assert !b_false.value? - b_true = Boolean.find(true_id) - assert b_true.value? - end - - def test_boolean_without_questionmark - b_true = Boolean.create("value" => true) - true_id = b_true.id + def test_attributes + category = Category.new(name: "Ruby") - subclass = Class.new(Boolean).find true_id - superclass = Boolean.find true_id + expected_attributes = category.attribute_names.map do |attribute_name| + [attribute_name, category.public_send(attribute_name)] + end.to_h - assert_equal superclass.read_attribute(:has_fun), subclass.read_attribute(:has_fun) - end - - def test_boolean_cast_from_string - b_blank = Boolean.create("value" => "") - blank_id = b_blank.id - b_false = Boolean.create("value" => "0") - false_id = b_false.id - b_true = Boolean.create("value" => "1") - true_id = b_true.id - - b_blank = Boolean.find(blank_id) - assert_nil b_blank.value - b_false = Boolean.find(false_id) - assert !b_false.value? - b_true = Boolean.find(true_id) - assert b_true.value? + assert_instance_of Hash, category.attributes + assert_equal expected_attributes, category.attributes end def test_new_record_returns_boolean @@ -755,7 +725,7 @@ class BasicsTest < ActiveRecord::TestCase duped_topic = nil assert_nothing_raised { duped_topic = topic.dup } assert_equal topic.title, duped_topic.title - assert !duped_topic.persisted? + assert_not_predicate duped_topic, :persisted? # test if the attributes have been duped topic.title = "a" @@ -773,7 +743,7 @@ class BasicsTest < ActiveRecord::TestCase # test if saved clone object differs from original duped_topic.save - assert duped_topic.persisted? + assert_predicate duped_topic, :persisted? assert_not_equal duped_topic.id, topic.id duped_topic.reload @@ -794,7 +764,7 @@ class BasicsTest < ActiveRecord::TestCase assert_nothing_raised { dup = dev.dup } assert_kind_of DeveloperSalary, dup.salary assert_equal dev.salary.amount, dup.salary.amount - assert !dup.persisted? + assert_not_predicate dup, :persisted? # test if the attributes have been duped original_amount = dup.salary.amount @@ -802,7 +772,7 @@ class BasicsTest < ActiveRecord::TestCase assert_equal original_amount, dup.salary.amount assert dup.save - assert dup.persisted? + assert_predicate dup, :persisted? assert_not_equal dup.id, dev.id end @@ -822,64 +792,68 @@ class BasicsTest < ActiveRecord::TestCase def test_clone_of_new_object_with_defaults developer = Developer.new - assert !developer.name_changed? - assert !developer.salary_changed? + assert_not_predicate developer, :name_changed? + assert_not_predicate developer, :salary_changed? cloned_developer = developer.clone - assert !cloned_developer.name_changed? - assert !cloned_developer.salary_changed? + assert_not_predicate cloned_developer, :name_changed? + assert_not_predicate cloned_developer, :salary_changed? end def test_clone_of_new_object_marks_attributes_as_dirty developer = Developer.new name: "Bjorn", salary: 100000 - assert developer.name_changed? - assert developer.salary_changed? + assert_predicate developer, :name_changed? + assert_predicate developer, :salary_changed? cloned_developer = developer.clone - assert cloned_developer.name_changed? - assert cloned_developer.salary_changed? + assert_predicate cloned_developer, :name_changed? + assert_predicate cloned_developer, :salary_changed? end def test_clone_of_new_object_marks_as_dirty_only_changed_attributes developer = Developer.new name: "Bjorn" assert developer.name_changed? # obviously - assert !developer.salary_changed? # attribute has non-nil default value, so treated as not changed + assert_not developer.salary_changed? # attribute has non-nil default value, so treated as not changed cloned_developer = developer.clone - assert cloned_developer.name_changed? - assert !cloned_developer.salary_changed? # ... and cloned instance should behave same + assert_predicate cloned_developer, :name_changed? + assert_not cloned_developer.salary_changed? # ... and cloned instance should behave same end def test_dup_of_saved_object_marks_attributes_as_dirty developer = Developer.create! name: "Bjorn", salary: 100000 - assert !developer.name_changed? - assert !developer.salary_changed? + assert_not_predicate developer, :name_changed? + assert_not_predicate developer, :salary_changed? cloned_developer = developer.dup assert cloned_developer.name_changed? # both attributes differ from defaults - assert cloned_developer.salary_changed? + assert_predicate cloned_developer, :salary_changed? end def test_dup_of_saved_object_marks_as_dirty_only_changed_attributes developer = Developer.create! name: "Bjorn" - assert !developer.name_changed? # both attributes of saved object should be treated as not changed - assert !developer.salary_changed? + assert_not developer.name_changed? # both attributes of saved object should be treated as not changed + assert_not_predicate developer, :salary_changed? cloned_developer = developer.dup assert cloned_developer.name_changed? # ... but on cloned object should be - assert !cloned_developer.salary_changed? # ... BUT salary has non-nil default which should be treated as not changed on cloned instance + assert_not cloned_developer.salary_changed? # ... BUT salary has non-nil default which should be treated as not changed on cloned instance end def test_bignum company = Company.find(1) - company.rating = 2147483647 + company.rating = 2147483648 company.save company = Company.find(1) - assert_equal 2147483647, company.rating + assert_equal 2147483648, company.rating + end + + def test_bignum_pk + company = Company.create!(id: 2147483648, name: "foo") + assert_equal company, Company.find(company.id) end - # TODO: extend defaults tests to other databases! - if current_adapter?(:PostgreSQLAdapter) + if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter, :SQLite3Adapter) def test_default with_timezone_config default: :local do default = Default.new @@ -891,85 +865,14 @@ class BasicsTest < ActiveRecord::TestCase # char types assert_equal "Y", default.char1 assert_equal "a varchar field", default.char2 - assert_equal "a text field", default.char3 + # Mysql text type can't have default value + unless current_adapter?(:Mysql2Adapter) + assert_equal "a text field", default.char3 + end end end end - class NumericData < ActiveRecord::Base - self.table_name = "numeric_data" - - attribute :my_house_population, :integer - attribute :atoms_in_universe, :integer - end - - def test_big_decimal_conditions - m = NumericData.new( - bank_balance: 1586.43, - big_bank_balance: BigDecimal("1000234000567.95"), - world_population: 6000000000, - my_house_population: 3 - ) - assert m.save - assert_equal 0, NumericData.where("bank_balance > ?", 2000.0).count - end - - def test_numeric_fields - m = NumericData.new( - bank_balance: 1586.43, - big_bank_balance: BigDecimal("1000234000567.95"), - world_population: 6000000000, - my_house_population: 3 - ) - assert m.save - - m1 = NumericData.find(m.id) - assert_not_nil m1 - - # As with migration_test.rb, we should make world_population >= 2**62 - # to cover 64-bit platforms and test it is a Bignum, but the main thing - # is that it's an Integer. - assert_kind_of Integer, m1.world_population - assert_equal 6000000000, m1.world_population - - assert_kind_of Integer, m1.my_house_population - assert_equal 3, m1.my_house_population - - assert_kind_of BigDecimal, m1.bank_balance - assert_equal BigDecimal("1586.43"), m1.bank_balance - - assert_kind_of BigDecimal, m1.big_bank_balance - assert_equal BigDecimal("1000234000567.95"), m1.big_bank_balance - end - - def test_numeric_fields_with_scale - m = NumericData.new( - bank_balance: 1586.43122334, - big_bank_balance: BigDecimal("234000567.952344"), - world_population: 6000000000, - my_house_population: 3 - ) - assert m.save - - m1 = NumericData.find(m.id) - assert_not_nil m1 - - # As with migration_test.rb, we should make world_population >= 2**62 - # to cover 64-bit platforms and test it is a Bignum, but the main thing - # is that it's an Integer. - assert_kind_of Integer, m1.world_population - assert_equal 6000000000, m1.world_population - - assert_kind_of Integer, m1.my_house_population - assert_equal 3, m1.my_house_population - - assert_kind_of BigDecimal, m1.bank_balance - assert_equal BigDecimal("1586.43"), m1.bank_balance - - assert_kind_of BigDecimal, m1.big_bank_balance - assert_equal BigDecimal("234000567.95"), m1.big_bank_balance - end - def test_auto_id auto = AutoId.new auto.save @@ -1007,14 +910,14 @@ class BasicsTest < ActiveRecord::TestCase end def test_toggle_attribute - assert !topics(:first).approved? + assert_not_predicate topics(:first), :approved? topics(:first).toggle!(:approved) - assert topics(:first).approved? + assert_predicate topics(:first), :approved? topic = topics(:first) topic.toggle(:approved) - assert !topic.approved? + assert_not_predicate topic, :approved? topic.reload - assert topic.approved? + assert_predicate topic, :approved? end def test_reload @@ -1038,7 +941,7 @@ class BasicsTest < ActiveRecord::TestCase end end - def test_clear_cash_when_setting_table_name + def test_clear_cache_when_setting_table_name original_table_name = Joke.table_name Joke.table_name = "funny_jokes" @@ -1115,29 +1018,15 @@ class BasicsTest < ActiveRecord::TestCase def test_count_with_join res = Post.count_by_sql "SELECT COUNT(*) FROM posts LEFT JOIN comments ON posts.id=comments.post_id WHERE posts.#{QUOTED_TYPE} = 'Post'" - res2 = Post.where("posts.#{QUOTED_TYPE} = 'Post'").joins("LEFT JOIN comments ON posts.id=comments.post_id").count assert_equal res, res2 - res3 = nil - assert_nothing_raised do - res3 = Post.where("posts.#{QUOTED_TYPE} = 'Post'").joins("LEFT JOIN comments ON posts.id=comments.post_id").count - end - assert_equal res, res3 - res4 = Post.count_by_sql "SELECT COUNT(p.id) FROM posts p, comments co WHERE p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id" - res5 = nil - assert_nothing_raised do - res5 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").count - end - + res5 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").count assert_equal res4, res5 res6 = Post.count_by_sql "SELECT COUNT(DISTINCT p.id) FROM posts p, comments co WHERE p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id" - res7 = nil - assert_nothing_raised do - res7 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").distinct.count - end + res7 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").distinct.count assert_equal res6, res7 end @@ -1501,28 +1390,92 @@ class BasicsTest < ActiveRecord::TestCase test "resetting column information doesn't remove attribute methods" do topic = topics(:first) - assert_not topic.id_changed? + assert_not_predicate topic, :id_changed? Topic.reset_column_information - assert_not topic.id_changed? + assert_not_predicate topic, :id_changed? end test "ignored columns are not present in columns_hash" do cache_columns = Developer.connection.schema_cache.columns_hash(Developer.table_name) assert_includes cache_columns.keys, "first_name" assert_not_includes Developer.columns_hash.keys, "first_name" + assert_not_includes SubDeveloper.columns_hash.keys, "first_name" + assert_not_includes SymbolIgnoredDeveloper.columns_hash.keys, "first_name" end test "ignored columns have no attribute methods" do - refute Developer.new.respond_to?(:first_name) - refute Developer.new.respond_to?(:first_name=) - refute Developer.new.respond_to?(:first_name?) + assert_not_respond_to Developer.new, :first_name + assert_not_respond_to Developer.new, :first_name= + assert_not_respond_to Developer.new, :first_name? + assert_not_respond_to SubDeveloper.new, :first_name + assert_not_respond_to SubDeveloper.new, :first_name= + assert_not_respond_to SubDeveloper.new, :first_name? + assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name + assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name= + assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name? end test "ignored columns don't prevent explicit declaration of attribute methods" do - assert Developer.new.respond_to?(:last_name) - assert Developer.new.respond_to?(:last_name=) - assert Developer.new.respond_to?(:last_name?) + assert_respond_to Developer.new, :last_name + assert_respond_to Developer.new, :last_name= + assert_respond_to Developer.new, :last_name? + assert_respond_to SubDeveloper.new, :last_name + assert_respond_to SubDeveloper.new, :last_name= + assert_respond_to SubDeveloper.new, :last_name? + assert_respond_to SymbolIgnoredDeveloper.new, :last_name + assert_respond_to SymbolIgnoredDeveloper.new, :last_name= + assert_respond_to SymbolIgnoredDeveloper.new, :last_name? + end + + test "ignored columns are stored as an array of string" do + assert_equal(%w(first_name last_name), Developer.ignored_columns) + assert_equal(%w(first_name last_name), SymbolIgnoredDeveloper.ignored_columns) + end + + test "when #reload called, ignored columns' attribute methods are not defined" do + developer = Developer.create!(name: "Developer") + assert_not_respond_to developer, :first_name + assert_not_respond_to developer, :first_name= + + developer.reload + + assert_not_respond_to developer, :first_name + assert_not_respond_to developer, :first_name= + end + + test "ignored columns not included in SELECT" do + query = Developer.all.to_sql.downcase + + # ignored column + assert_not query.include?("first_name") + + # regular column + assert query.include?("name") + end + + test "column names are quoted when using #from clause and model has ignored columns" do + assert_not_empty Developer.ignored_columns + query = Developer.from("developers").to_sql + quoted_id = "#{Developer.quoted_table_name}.#{Developer.quoted_primary_key}" + + assert_match(/SELECT #{Regexp.escape(quoted_id)}.* FROM developers/, query) + end + + test "using table name qualified column names unless having SELECT list explicitly" do + assert_equal developers(:david), Developer.from("developers").joins(:shared_computers).take + end + + test "protected environments by default is an array with production" do + assert_equal ["production"], ActiveRecord::Base.protected_environments + end + + def test_protected_environments_are_stored_as_an_array_of_string + previous_protected_environments = ActiveRecord::Base.protected_environments + ActiveRecord::Base.protected_environments = [:staging, "production"] + assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments + ensure + ActiveRecord::Base.protected_environments = previous_protected_environments end end diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb index f7e21faf0f..c8163901c6 100644 --- a/activerecord/test/cases/batches_test.rb +++ b/activerecord/test/cases/batches_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "cases/helper" +require "models/comment" require "models/post" require "models/subscriber" @@ -35,12 +38,10 @@ class EachTest < ActiveRecord::TestCase end end - if Enumerator.method_defined? :size - def test_each_should_return_a_sized_enumerator - assert_equal 11, Post.find_each(batch_size: 1).size - assert_equal 5, Post.find_each(batch_size: 2, start: 7).size - assert_equal 11, Post.find_each(batch_size: 10_000).size - end + def test_each_should_return_a_sized_enumerator + assert_equal 11, Post.find_each(batch_size: 1).size + assert_equal 5, Post.find_each(batch_size: 2, start: 7).size + assert_equal 11, Post.find_each(batch_size: 10_000).size end def test_each_enumerator_should_execute_one_query_per_batch @@ -145,7 +146,7 @@ class EachTest < ActiveRecord::TestCase def test_find_in_batches_should_quote_batch_order c = Post.connection - assert_sql(/ORDER BY #{c.quote_table_name('posts')}.#{c.quote_column_name('id')}/) do + assert_sql(/ORDER BY #{c.quote_table_name('posts')}\.#{c.quote_column_name('id')}/) do Post.find_in_batches(batch_size: 1) do |batch| assert_kind_of Array, batch assert_kind_of Post, batch.first @@ -154,7 +155,7 @@ class EachTest < ActiveRecord::TestCase end def test_find_in_batches_should_not_use_records_after_yielding_them_in_case_original_array_is_modified - not_a_post = "not a post" + not_a_post = "not a post".dup def not_a_post.id; end not_a_post.stub(:id, -> { raise StandardError.new("not_a_post had #id called on it") }) do assert_nothing_raised do @@ -312,7 +313,7 @@ class EachTest < ActiveRecord::TestCase def test_in_batches_each_record_should_yield_record_if_block_is_given assert_queries(6) do Post.in_batches(of: 2).each_record do |post| - assert post.title.present? + assert_predicate post.title, :present? assert_kind_of Post, post end end @@ -321,7 +322,7 @@ class EachTest < ActiveRecord::TestCase def test_in_batches_each_record_should_return_enumerator_if_no_block_given assert_queries(6) do Post.in_batches(of: 2).each_record.with_index do |post, i| - assert post.title.present? + assert_predicate post.title, :present? assert_kind_of Post, post end end @@ -352,24 +353,24 @@ class EachTest < ActiveRecord::TestCase def test_in_batches_should_not_be_loaded Post.in_batches(of: 1) do |relation| - assert_not relation.loaded? + assert_not_predicate relation, :loaded? end Post.in_batches(of: 1, load: false) do |relation| - assert_not relation.loaded? + assert_not_predicate relation, :loaded? end end def test_in_batches_should_be_loaded Post.in_batches(of: 1, load: true) do |relation| - assert relation.loaded? + assert_predicate relation, :loaded? end end def test_in_batches_if_not_loaded_executes_more_queries assert_queries(@total + 1) do Post.in_batches(of: 1, load: false) do |relation| - assert_not relation.loaded? + assert_not_predicate relation, :loaded? end end end @@ -410,7 +411,7 @@ class EachTest < ActiveRecord::TestCase def test_in_batches_should_quote_batch_order c = Post.connection - assert_sql(/ORDER BY #{c.quote_table_name('posts')}.#{c.quote_column_name('id')}/) do + assert_sql(/ORDER BY #{c.quote_table_name('posts')}\.#{c.quote_column_name('id')}/) do Post.in_batches(of: 1) do |relation| assert_kind_of ActiveRecord::Relation, relation assert_kind_of Post, relation.first @@ -419,7 +420,7 @@ class EachTest < ActiveRecord::TestCase end def test_in_batches_should_not_use_records_after_yielding_them_in_case_original_array_is_modified - not_a_post = "not a post" + not_a_post = "not a post".dup def not_a_post.id raise StandardError.new("not_a_post had #id called on it") end @@ -507,7 +508,7 @@ class EachTest < ActiveRecord::TestCase def test_in_batches_relations_update_all_should_not_affect_matching_records_in_other_batches Post.update_all(author_id: 0) person = Post.last - person.update_attributes(author_id: 1) + person.update(author_id: 1) Post.in_batches(of: 2) do |batch| batch.where("author_id >= 1").update_all("author_id = author_id + 1") @@ -515,14 +516,12 @@ class EachTest < ActiveRecord::TestCase assert_equal 2, person.reload.author_id # incremented only once end - if Enumerator.method_defined? :size - def test_find_in_batches_should_return_a_sized_enumerator - assert_equal 11, Post.find_in_batches(batch_size: 1).size - assert_equal 6, Post.find_in_batches(batch_size: 2).size - assert_equal 4, Post.find_in_batches(batch_size: 2, start: 4).size - assert_equal 4, Post.find_in_batches(batch_size: 3).size - assert_equal 1, Post.find_in_batches(batch_size: 10_000).size - end + def test_find_in_batches_should_return_a_sized_enumerator + assert_equal 11, Post.find_in_batches(batch_size: 1).size + assert_equal 6, Post.find_in_batches(batch_size: 2).size + assert_equal 4, Post.find_in_batches(batch_size: 2, start: 4).size + assert_equal 4, Post.find_in_batches(batch_size: 3).size + assert_equal 1, Post.find_in_batches(batch_size: 10_000).size end [true, false].each do |load| @@ -587,31 +586,78 @@ class EachTest < ActiveRecord::TestCase end end - test ".error_on_ignored_order_or_limit= is deprecated" do - begin - prev = ActiveRecord::Base.error_on_ignored_order - assert_deprecated "Please use error_on_ignored_order= instead." do - ActiveRecord::Base.error_on_ignored_order_or_limit = true + test ".find_each respects table alias" do + assert_queries(1) do + table_alias = Post.arel_table.alias("omg_posts") + table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias) + predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) + + posts = ActiveRecord::Relation.create( + Post, + table: table_alias, + predicate_builder: predicate_builder + ) + posts.find_each {} + end + end + + test ".find_each bypasses the query cache for its own queries" do + Post.cache do + assert_queries(2) do + Post.find_each {} + Post.find_each {} end - assert ActiveRecord::Base.error_on_ignored_order - ensure - ActiveRecord::Base.error_on_ignored_order = prev end end - test ".error_on_ignored_order_or_limit is deprecated" do - expected = ActiveRecord::Base.error_on_ignored_order - actual = assert_deprecated "Please use error_on_ignored_order instead." do - ActiveRecord::Base.error_on_ignored_order_or_limit + test ".find_each does not disable the query cache inside the given block" do + Post.cache do + Post.find_each(start: 1, finish: 1) do |post| + assert_queries(1) do + post.comments.count + post.comments.count + end + end end - assert_equal expected, actual end - test "#error_on_ignored_order_or_limit is deprecated" do - expected = ActiveRecord::Base.error_on_ignored_order - actual = assert_deprecated "Please use error_on_ignored_order instead." do - Post.new.error_on_ignored_order_or_limit + test ".find_in_batches bypasses the query cache for its own queries" do + Post.cache do + assert_queries(2) do + Post.find_in_batches {} + Post.find_in_batches {} + end + end + end + + test ".find_in_batches does not disable the query cache inside the given block" do + Post.cache do + Post.find_in_batches(start: 1, finish: 1) do |batch| + assert_queries(1) do + batch.first.comments.count + batch.first.comments.count + end + end + end + end + + test ".in_batches bypasses the query cache for its own queries" do + Post.cache do + assert_queries(2) do + Post.in_batches {} + Post.in_batches {} + end + end + end + + test ".in_batches does not disable the query cache inside the given block" do + Post.cache do + Post.in_batches(start: 1, finish: 1) do |relation| + assert_queries(1) do + relation.count + relation.count + end + end end - assert_equal expected, actual end end diff --git a/activerecord/test/cases/binary_test.rb b/activerecord/test/cases/binary_test.rb index 1fc30e24d2..d5376ece69 100644 --- a/activerecord/test/cases/binary_test.rb +++ b/activerecord/test/cases/binary_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" # Without using prepared statements, it makes no sense to test @@ -10,7 +12,7 @@ unless current_adapter?(:DB2Adapter) FIXTURES = %w(flowers.jpg example.log test.txt) def test_mixed_encoding - str = "\x80" + str = "\x80".dup str.force_encoding("ASCII-8BIT") binary = Binary.new name: "いただきます!", data: str diff --git a/activerecord/test/cases/bind_parameter_test.rb b/activerecord/test/cases/bind_parameter_test.rb index 98d202dd79..91cc49385c 100644 --- a/activerecord/test/cases/bind_parameter_test.rb +++ b/activerecord/test/cases/bind_parameter_test.rb @@ -1,38 +1,39 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/author" require "models/post" -module ActiveRecord - class BindParameterTest < ActiveRecord::TestCase - fixtures :topics, :authors, :posts +if ActiveRecord::Base.connection.prepared_statements + module ActiveRecord + class BindParameterTest < ActiveRecord::TestCase + fixtures :topics, :authors, :author_addresses, :posts - class LogListener - attr_accessor :calls + class LogListener + attr_accessor :calls - def initialize - @calls = [] - end + def initialize + @calls = [] + end - def call(*args) - calls << args + def call(*args) + calls << args + end end - end - def setup - super - @connection = ActiveRecord::Base.connection - @subscriber = LogListener.new - @pk = Topic.columns_hash[Topic.primary_key] - @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber) - end + def setup + super + @connection = ActiveRecord::Base.connection + @subscriber = LogListener.new + @pk = Topic.columns_hash[Topic.primary_key] + @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber) + end - teardown do - ActiveSupport::Notifications.unsubscribe(@subscription) - end + def teardown + ActiveSupport::Notifications.unsubscribe(@subscription) + end - if ActiveRecord::Base.connection.supports_statement_cache? && - ActiveRecord::Base.connection.prepared_statements def test_bind_from_join_in_subquery subquery = Author.joins(:thinking_posts).where(name: "David") scope = Author.from(subquery, "authors").where(id: 1) @@ -40,7 +41,7 @@ module ActiveRecord end def test_binds_are_logged - sub = Arel::Nodes::BindParam.new + sub = Arel::Nodes::BindParam.new(1) binds = [Relation::QueryAttribute.new("id", 1, Type::Value.new)] sql = "select * from topics where id = #{sub.to_sql}" @@ -56,43 +57,52 @@ module ActiveRecord assert message, "expected a message with binds" end - def test_logs_bind_vars_after_type_cast + def test_logs_binds_after_type_cast binds = [Relation::QueryAttribute.new("id", "10", Type::Integer.new)] - type_casted_binds = binds.map { |attr| type_cast(attr.value_for_database) } - payload = { - name: "SQL", - sql: "select * from topics where id = ?", - binds: binds, - type_casted_binds: type_casted_binds - } - event = ActiveSupport::Notifications::Event.new( - "foo", - Time.now, - Time.now, - 123, - payload) - - logger = Class.new(ActiveRecord::LogSubscriber) { - attr_reader :debugs - def initialize - super - @debugs = [] - end - - def debug(str) - @debugs << str - end - }.new - - logger.sql event - assert_match([[@pk.name, 10]].inspect, logger.debugs.first) + assert_logs_binds(binds) end - private + def test_logs_legacy_binds_after_type_cast + binds = [[@pk, "10"]] + assert_logs_binds(binds) + end - def type_cast(value) - ActiveRecord::Base.connection.type_cast(value) + def test_deprecate_supports_statement_cache + assert_deprecated { ActiveRecord::Base.connection.supports_statement_cache? } end + + private + def assert_logs_binds(binds) + payload = { + name: "SQL", + sql: "select * from topics where id = ?", + binds: binds, + type_casted_binds: @connection.type_casted_binds(binds) + } + + event = ActiveSupport::Notifications::Event.new( + "foo", + Time.now, + Time.now, + 123, + payload) + + logger = Class.new(ActiveRecord::LogSubscriber) { + attr_reader :debugs + + def initialize + super + @debugs = [] + end + + def debug(str) + @debugs << str + end + }.new + + logger.sql(event) + assert_match([[@pk.name, 10]].inspect, logger.debugs.first) + end end end end diff --git a/activerecord/test/cases/boolean_test.rb b/activerecord/test/cases/boolean_test.rb new file mode 100644 index 0000000000..ab9f974e2c --- /dev/null +++ b/activerecord/test/cases/boolean_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/boolean" + +class BooleanTest < ActiveRecord::TestCase + def test_boolean + b_nil = Boolean.create!(value: nil) + b_false = Boolean.create!(value: false) + b_true = Boolean.create!(value: true) + + assert_nil Boolean.find(b_nil.id).value + assert_not_predicate Boolean.find(b_false.id), :value? + assert_predicate Boolean.find(b_true.id), :value? + end + + def test_boolean_without_questionmark + b_true = Boolean.create!(value: true) + + subclass = Class.new(Boolean).find(b_true.id) + superclass = Boolean.find(b_true.id) + + assert_equal superclass.read_attribute(:has_fun), subclass.read_attribute(:has_fun) + end + + def test_boolean_cast_from_string + b_blank = Boolean.create!(value: "") + b_false = Boolean.create!(value: "0") + b_true = Boolean.create!(value: "1") + + assert_nil Boolean.find(b_blank.id).value + assert_not_predicate Boolean.find(b_false.id), :value? + assert_predicate Boolean.find(b_true.id), :value? + end + + def test_find_by_boolean_string + b_false = Boolean.create!(value: "false") + b_true = Boolean.create!(value: "true") + + assert_equal b_false, Boolean.find_by(value: "false") + assert_equal b_true, Boolean.find_by(value: "true") + end +end diff --git a/activerecord/test/cases/cache_key_test.rb b/activerecord/test/cases/cache_key_test.rb index bb2829b3c1..3a569f226e 100644 --- a/activerecord/test/cases/cache_key_test.rb +++ b/activerecord/test/cases/cache_key_test.rb @@ -1,25 +1,53 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord class CacheKeyTest < ActiveRecord::TestCase self.use_transactional_tests = false - class CacheMe < ActiveRecord::Base; end + class CacheMe < ActiveRecord::Base + self.cache_versioning = false + end + + class CacheMeWithVersion < ActiveRecord::Base + self.cache_versioning = true + end setup do @connection = ActiveRecord::Base.connection - @connection.create_table(:cache_mes) { |t| t.timestamps } + @connection.create_table(:cache_mes, force: true) { |t| t.timestamps } + @connection.create_table(:cache_me_with_versions, force: true) { |t| t.timestamps } end teardown do @connection.drop_table :cache_mes, if_exists: true + @connection.drop_table :cache_me_with_versions, if_exists: true end - test "test_cache_key_format_is_not_too_precise" do + test "cache_key format is not too precise" do record = CacheMe.create key = record.cache_key assert_equal key, record.reload.cache_key end + + test "cache_key has no version when versioning is on" do + record = CacheMeWithVersion.create + assert_equal "active_record/cache_key_test/cache_me_with_versions/#{record.id}", record.cache_key + end + + test "cache_version is only there when versioning is on" do + assert_predicate CacheMeWithVersion.create.cache_version, :present? + assert_not_predicate CacheMe.create.cache_version, :present? + end + + test "cache_key_with_version always has both key and version" do + r1 = CacheMeWithVersion.create + assert_equal "active_record/cache_key_test/cache_me_with_versions/#{r1.id}-#{r1.updated_at.to_s(:usec)}", r1.cache_key_with_version + + r2 = CacheMe.create + assert_equal "active_record/cache_key_test/cache_mes/#{r2.id}-#{r2.updated_at.to_s(:usec)}", r2.cache_key_with_version + end end end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index edf7ce0bca..5c9ed42173 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/book" require "models/club" @@ -8,6 +10,7 @@ require "models/organization" require "models/possession" require "models/topic" require "models/reply" +require "models/numeric_data" require "models/minivan" require "models/speedometer" require "models/ship_part" @@ -17,16 +20,8 @@ require "models/post" require "models/comment" require "models/rating" -class NumericData < ActiveRecord::Base - self.table_name = "numeric_data" - - attribute :world_population, :integer - attribute :my_house_population, :integer - attribute :atoms_in_universe, :integer -end - class CalculationsTest < ActiveRecord::TestCase - fixtures :companies, :accounts, :topics, :speedometers, :minivans, :books + fixtures :companies, :accounts, :topics, :speedometers, :minivans, :books, :posts, :comments def test_should_sum_field assert_equal 318, Account.sum(:credit_limit) @@ -241,6 +236,52 @@ class CalculationsTest < ActiveRecord::TestCase end end + def test_count_with_eager_loading_and_custom_order + posts = Post.includes(:comments).order("comments.id") + 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 } + assert_queries(1) { assert_equal 11, posts.count(:all) } + end + + def test_distinct_count_all_with_custom_select_and_order + accounts = Account.distinct.select("credit_limit % 10").order(Arel.sql("credit_limit % 10")) + assert_queries(1) { assert_equal 3, accounts.count(:all) } + assert_queries(1) { assert_equal 3, accounts.load.size } + end + + def test_distinct_count_with_order_and_limit + assert_equal 4, Account.distinct.order(:firm_id).limit(4).count + end + + def test_distinct_count_with_order_and_offset + assert_equal 4, Account.distinct.order(:firm_id).offset(2).count + end + + def test_distinct_count_with_order_and_limit_and_offset + assert_equal 4, Account.distinct.order(:firm_id).limit(4).offset(2).count + end + + def test_distinct_joins_count_with_order_and_limit + assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).count + end + + def test_distinct_joins_count_with_order_and_offset + assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).offset(2).count + end + + def test_distinct_joins_count_with_order_and_limit_and_offset + assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).offset(2).count + end + + def test_distinct_count_with_group_by_and_order_and_limit + assert_equal({ 6 => 2 }, Account.group(:firm_id).distinct.order("1 DESC").limit(1).count) + end + def test_should_group_by_summed_field_having_condition c = Account.group(:firm_id).having("sum(credit_limit) > 50").sum(:credit_limit) assert_nil c[1] @@ -249,7 +290,7 @@ class CalculationsTest < ActiveRecord::TestCase end def test_should_group_by_summed_field_having_condition_from_select - skip if current_adapter?(:PostgreSQLAdapter) + skip unless current_adapter?(:Mysql2Adapter, :SQLite3Adapter) c = Account.select("MIN(credit_limit) AS min_credit_limit").group(:firm_id).having("min_credit_limit > 50").sum(:credit_limit) assert_nil c[1] assert_equal 60, c[2] @@ -499,8 +540,7 @@ class CalculationsTest < ActiveRecord::TestCase end def test_should_sum_expression - # Oracle adapter returns floating point value 636.0 after SUM - if current_adapter?(:OracleAdapter) + if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter) assert_equal 636, Account.sum("2 * credit_limit") else assert_equal 636, Account.sum("2 * credit_limit").to_i @@ -587,8 +627,11 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_without_column_names - assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, ""]], - Company.order(:id).limit(1).pluck + if current_adapter?(:OracleAdapter) + assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, nil]], Company.order(:id).limit(1).pluck + else + assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, ""]], Company.order(:id).limit(1).pluck + end end def test_pluck_type_cast @@ -599,6 +642,18 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal [ topic.written_on ], relation.pluck(:written_on) end + def test_pluck_with_type_cast_does_not_corrupt_the_query_cache + topic = topics(:first) + relation = Topic.where(id: topic.id) + assert_queries 1 do + Topic.cache do + kind = relation.select(:written_on).load.first.read_attribute_before_type_cast(:written_on).class + relation.pluck(:written_on) + assert_kind_of kind, relation.select(:written_on).load.first.read_attribute_before_type_cast(:written_on) + end + end + end + def test_pluck_and_distinct assert_equal [50, 53, 55, 60], Account.order(:credit_limit).distinct.pluck(:credit_limit) end @@ -638,14 +693,14 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_with_selection_clause - assert_equal [50, 53, 55, 60], Account.pluck("DISTINCT credit_limit").sort - assert_equal [50, 53, 55, 60], Account.pluck("DISTINCT accounts.credit_limit").sort - assert_equal [50, 53, 55, 60], Account.pluck("DISTINCT(credit_limit)").sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT credit_limit")).sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT accounts.credit_limit")).sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT(credit_limit)")).sort # MySQL returns "SUM(DISTINCT(credit_limit))" as the column name unless # an alias is provided. Without the alias, the column cannot be found # and properly typecast. - assert_equal [50 + 53 + 55 + 60], Account.pluck("SUM(DISTINCT(credit_limit)) as credit_limit") + assert_equal [50 + 53 + 55 + 60], Account.pluck(Arel.sql("SUM(DISTINCT(credit_limit)) as credit_limit")) end def test_plucks_with_ids @@ -657,6 +712,29 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal [], Topic.includes(:replies).limit(1).where("0 = 1").pluck(:id) end + def test_pluck_with_includes_offset + assert_equal [5], Topic.includes(:replies).order(:id).offset(4).pluck(:id) + assert_equal [], Topic.includes(:replies).order(:id).offset(5).pluck(:id) + end + + def test_group_by_with_limit + expected = { "Post" => 8, "SpecialPost" => 1 } + actual = Post.includes(:comments).group(:type).order(:type).limit(2).count("comments.id") + assert_equal expected, actual + end + + def test_group_by_with_offset + expected = { "SpecialPost" => 1, "StiPost" => 2 } + actual = Post.includes(:comments).group(:type).order(:type).offset(1).count("comments.id") + assert_equal expected, actual + end + + def test_group_by_with_limit_and_offset + expected = { "SpecialPost" => 1 } + actual = Post.includes(:comments).group(:type).order(:type).offset(1).limit(1).count("comments.id") + assert_equal expected, actual + end + def test_pluck_not_auto_table_name_prefix_if_column_included Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) ids = Company.includes(:contracts).pluck(:developer_id) @@ -725,26 +803,49 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_loaded_relation + Company.attribute_names # Load schema information so we don't query below companies = Company.order(:id).limit(3).load + assert_no_queries do assert_equal ["37signals", "Summit", "Microsoft"], companies.pluck(:name) end end def test_pluck_loaded_relation_multiple_columns + Company.attribute_names # Load schema information so we don't query below companies = Company.order(:id).limit(3).load + assert_no_queries do assert_equal [[1, "37signals"], [2, "Summit"], [3, "Microsoft"]], companies.pluck(:id, :name) end end def test_pluck_loaded_relation_sql_fragment + Company.attribute_names # Load schema information so we don't query below companies = Company.order(:name).limit(3).load + assert_queries 1 do - assert_equal ["37signals", "Apex", "Ex Nihilo"], companies.pluck("DISTINCT name") + assert_equal ["37signals", "Apex", "Ex Nihilo"], companies.pluck(Arel.sql("DISTINCT name")) end end + def test_pick_one + assert_equal "The First Topic", Topic.order(:id).pick(:heading) + assert_nil Topic.none.pick(:heading) + assert_nil Topic.where("1=0").pick(:heading) + end + + def test_pick_two + assert_equal ["David", "david@loudthinking.com"], Topic.order(:id).pick(:author_name, :author_email_address) + assert_nil Topic.none.pick(:author_name, :author_email_address) + assert_nil Topic.where("1=0").pick(:author_name, :author_email_address) + end + + def test_pick_delegate_to_all + cool_first = minivans(:cool_first) + assert_equal cool_first.color, Minivan.pick(:color) + end + def test_grouped_calculation_with_polymorphic_relation part = ShipPart.create!(name: "has trinket") part.trinkets.create! @@ -809,4 +910,58 @@ class CalculationsTest < ActiveRecord::TestCase def test_group_by_attribute_with_custom_type assert_equal({ "proposed" => 2, "published" => 2 }, Book.group(:status).count) end + + def test_deprecate_count_with_block_and_column_name + assert_deprecated do + assert_equal 6, Account.count(:firm_id) { true } + end + end + + def test_deprecate_sum_with_block_and_column_name + assert_deprecated do + assert_equal 6, Account.sum(:firm_id) { 1 } + end + end + + test "#skip_query_cache! for #pluck" do + Account.cache do + assert_queries(1) do + Account.pluck(:credit_limit) + Account.pluck(:credit_limit) + end + + assert_queries(2) do + Account.all.skip_query_cache!.pluck(:credit_limit) + Account.all.skip_query_cache!.pluck(:credit_limit) + end + end + end + + test "#skip_query_cache! for a simple calculation" do + Account.cache do + assert_queries(1) do + Account.calculate(:sum, :credit_limit) + Account.calculate(:sum, :credit_limit) + end + + assert_queries(2) do + Account.all.skip_query_cache!.calculate(:sum, :credit_limit) + Account.all.skip_query_cache!.calculate(:sum, :credit_limit) + end + end + end + + test "#skip_query_cache! for a grouped calculation" do + Account.cache do + assert_queries(1) do + Account.group(:firm_id).calculate(:sum, :credit_limit) + Account.group(:firm_id).calculate(:sum, :credit_limit) + end + + assert_queries(2) do + Account.all.skip_query_cache!.group(:firm_id).calculate(:sum, :credit_limit) + Account.all.skip_query_cache!.group(:firm_id).calculate(:sum, :credit_limit) + end + end + end end diff --git a/activerecord/test/cases/callbacks_test.rb b/activerecord/test/cases/callbacks_test.rb index b3c86586d0..253c3099d6 100644 --- a/activerecord/test/cases/callbacks_test.rb +++ b/activerecord/test/cases/callbacks_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/developer" require "models/computer" @@ -383,60 +385,60 @@ class CallbacksTest < ActiveRecord::TestCase end def assert_save_callbacks_not_called(someone) - assert !someone.after_save_called - assert !someone.after_create_called - assert !someone.after_update_called + assert_not someone.after_save_called + assert_not someone.after_create_called + assert_not someone.after_update_called end private :assert_save_callbacks_not_called def test_before_create_throwing_abort someone = CallbackHaltedDeveloper.new someone.cancel_before_create = true - assert someone.valid? - assert !someone.save + assert_predicate someone, :valid? + assert_not someone.save assert_save_callbacks_not_called(someone) end def test_before_save_throwing_abort david = DeveloperWithCanceledCallbacks.find(1) - assert david.valid? - assert !david.save + assert_predicate david, :valid? + assert_not david.save exc = assert_raise(ActiveRecord::RecordNotSaved) { david.save! } assert_equal david, exc.record david = DeveloperWithCanceledCallbacks.find(1) david.salary = 10_000_000 - assert !david.valid? - assert !david.save + assert_not_predicate david, :valid? + assert_not david.save assert_raise(ActiveRecord::RecordInvalid) { david.save! } someone = CallbackHaltedDeveloper.find(1) someone.cancel_before_save = true - assert someone.valid? - assert !someone.save + assert_predicate someone, :valid? + assert_not someone.save assert_save_callbacks_not_called(someone) end def test_before_update_throwing_abort someone = CallbackHaltedDeveloper.find(1) someone.cancel_before_update = true - assert someone.valid? - assert !someone.save + assert_predicate someone, :valid? + assert_not someone.save assert_save_callbacks_not_called(someone) end def test_before_destroy_throwing_abort david = DeveloperWithCanceledCallbacks.find(1) - assert !david.destroy + assert_not david.destroy exc = assert_raise(ActiveRecord::RecordNotDestroyed) { david.destroy! } assert_equal david, exc.record assert_not_nil ImmutableDeveloper.find_by_id(1) someone = CallbackHaltedDeveloper.find(1) someone.cancel_before_destroy = true - assert !someone.destroy + assert_not someone.destroy assert_raise(ActiveRecord::RecordNotDestroyed) { someone.destroy! } - assert !someone.after_destroy_called + assert_not someone.after_destroy_called end def test_callback_throwing_abort @@ -465,13 +467,40 @@ class CallbacksTest < ActiveRecord::TestCase def test_inheritance_of_callbacks parent = ParentDeveloper.new - assert !parent.after_save_called + assert_not parent.after_save_called parent.save assert parent.after_save_called child = ChildDeveloper.new - assert !child.after_save_called + assert_not child.after_save_called child.save assert child.after_save_called end + + def test_before_save_doesnt_allow_on_option + exception = assert_raises ArgumentError do + Class.new(ActiveRecord::Base) do + before_save(on: :create) {} + end + end + assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message + end + + def test_around_save_doesnt_allow_on_option + exception = assert_raises ArgumentError do + Class.new(ActiveRecord::Base) do + around_save(on: :create) {} + end + end + assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message + end + + def test_after_save_doesnt_allow_on_option + exception = assert_raises ArgumentError do + Class.new(ActiveRecord::Base) do + after_save(on: :create) {} + end + end + assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message + end end diff --git a/activerecord/test/cases/clone_test.rb b/activerecord/test/cases/clone_test.rb index b89294c094..eea36ee736 100644 --- a/activerecord/test/cases/clone_test.rb +++ b/activerecord/test/cases/clone_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" @@ -10,7 +12,7 @@ module ActiveRecord cloned = topic.clone assert topic.persisted?, "topic persisted" assert cloned.persisted?, "topic persisted" - assert !cloned.new_record?, "topic is not new" + assert_not cloned.new_record?, "topic is not new" end def test_stays_frozen @@ -19,7 +21,7 @@ module ActiveRecord cloned = topic.clone assert cloned.persisted?, "topic persisted" - assert !cloned.new_record?, "topic is not new" + assert_not cloned.new_record?, "topic is not new" assert cloned.frozen?, "topic should be frozen" end @@ -34,7 +36,7 @@ module ActiveRecord cloned = Topic.new clone = cloned.clone cloned.freeze - assert_not clone.frozen? + assert_not_predicate clone, :frozen? end end end diff --git a/activerecord/test/cases/coders/json_test.rb b/activerecord/test/cases/coders/json_test.rb index d22d93d129..e40d576b39 100644 --- a/activerecord/test/cases/coders/json_test.rb +++ b/activerecord/test/cases/coders/json_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/coders/yaml_column_test.rb b/activerecord/test/cases/coders/yaml_column_test.rb index 59ef389326..4a5559c62f 100644 --- a/activerecord/test/cases/coders/yaml_column_test.rb +++ b/activerecord/test/cases/coders/yaml_column_test.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require "cases/helper" diff --git a/activerecord/test/cases/collection_cache_key_test.rb b/activerecord/test/cases/collection_cache_key_test.rb index 381a78a8e2..a5d908344a 100644 --- a/activerecord/test/cases/collection_cache_key_test.rb +++ b/activerecord/test/cases/collection_cache_key_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/computer" require "models/developer" @@ -11,22 +13,80 @@ module ActiveRecord fixtures :developers, :projects, :developers_projects, :topics, :comments, :posts test "collection_cache_key on model" do - assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, Developer.collection_cache_key) + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, Developer.collection_cache_key) end test "cache_key for relation" do - developers = Developer.where(name: "David") - last_developer_timestamp = developers.order(updated_at: :desc).first.updated_at + developers = Developer.where(salary: 100000).order(updated_at: :desc) + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for relation with limit" do + developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5) + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for loaded relation" do + developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5).load + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) - assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, developers.cache_key) + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key - /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/ =~ developers.cache_key + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for relation with table alias" do + table_alias = Developer.arel_table.alias("omg_developers") + table_metadata = ActiveRecord::TableMetadata.new(Developer, table_alias) + predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) + + developers = ActiveRecord::Relation.create( + Developer, + table: table_alias, + predicate_builder: predicate_builder + ) + developers = developers.where(salary: 100000).order(updated_at: :desc) + last_developer_timestamp = developers.first.updated_at - assert_equal Digest::MD5.hexdigest(developers.to_sql), $1 + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 assert_equal developers.count.to_s, $2 assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 end + test "cache_key for relation with includes" do + comments = Comment.includes(:post).where("posts.type": "Post") + assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key) + end + + test "cache_key for loaded relation with includes" do + comments = Comment.includes(:post).where("posts.type": "Post").load + assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key) + end + test "it triggers at most one query" do developers = Developer.where(name: "David") @@ -48,7 +108,7 @@ module ActiveRecord test "cache_key for empty relation" do developers = Developer.where(name: "Non Existent Developer") - assert_match(/\Adevelopers\/query-(\h+)-0\Z/, developers.cache_key) + assert_match(/\Adevelopers\/query-(\h+)-0\z/, developers.cache_key) end test "cache_key with custom timestamp column" do @@ -64,7 +124,7 @@ module ActiveRecord test "collection proxy provides a cache_key" do developers = projects(:active_record).developers - assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, developers.cache_key) + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) end test "cache_key for loaded collection with zero size" do @@ -72,18 +132,30 @@ module ActiveRecord posts = Post.includes(:comments) empty_loaded_collection = posts.first.comments - assert_match(/\Acomments\/query-(\h+)-0\Z/, empty_loaded_collection.cache_key) + assert_match(/\Acomments\/query-(\h+)-0\z/, empty_loaded_collection.cache_key) end test "cache_key for queries with offset which return 0 rows" do developers = Developer.offset(20) - assert_match(/\Adevelopers\/query-(\h+)-0\Z/, developers.cache_key) + assert_match(/\Adevelopers\/query-(\h+)-0\z/, developers.cache_key) end test "cache_key with a relation having selected columns" do developers = Developer.select(:salary) - assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\Z/, developers.cache_key) + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + end + + test "cache_key with a relation having distinct and order" do + developers = Developer.distinct.order(:salary).limit(5) + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + end + + test "cache_key with a relation having custom select and order" do + developers = Developer.select("name AS dev_name").order("dev_name DESC").limit(5) + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) end end end diff --git a/activerecord/test/cases/column_alias_test.rb b/activerecord/test/cases/column_alias_test.rb index 9893ba9580..a883d21fb8 100644 --- a/activerecord/test/cases/column_alias_test.rb +++ b/activerecord/test/cases/column_alias_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" diff --git a/activerecord/test/cases/column_definition_test.rb b/activerecord/test/cases/column_definition_test.rb index d230700119..cbd2b44589 100644 --- a/activerecord/test/cases/column_definition_test.rb +++ b/activerecord/test/cases/column_definition_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -8,7 +10,7 @@ module ActiveRecord def @adapter.native_database_types { string: "varchar" } end - @viz = @adapter.schema_creation + @viz = @adapter.send(:schema_creation) end # Avoid column definitions in create table statements like: diff --git a/activerecord/test/cases/comment_test.rb b/activerecord/test/cases/comment_test.rb index 63f67a9a16..584e03d196 100644 --- a/activerecord/test/cases/comment_test.rb +++ b/activerecord/test/cases/comment_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -72,9 +74,11 @@ if ActiveRecord::Base.connection.supports_comments? end def test_add_index_with_comment_later - @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments" - index = @connection.indexes("commenteds").find { |idef| idef.name == "idx_obvious" } - assert_equal "We need to see obvious comments", index.comment + unless current_adapter?(:OracleAdapter) + @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments" + index = @connection.indexes("commenteds").find { |idef| idef.name == "idx_obvious" } + assert_equal "We need to see obvious comments", index.comment + end end def test_add_comment_to_column @@ -107,13 +111,17 @@ if ActiveRecord::Base.connection.supports_comments? # And check that these changes are reflected in dump output = dump_table_schema "commenteds" - assert_match %r[create_table "commenteds",.+\s+comment: "A table with comment"], output + assert_match %r[create_table "commenteds",.*\s+comment: "A table with comment"], output assert_match %r[t\.string\s+"name",\s+comment: "Comment should help clarify the column purpose"], output assert_match %r[t\.string\s+"obvious"\n], output assert_match %r[t\.string\s+"content",\s+comment: "Whoa, content describes itself!"], output - assert_match %r[t\.integer\s+"rating",\s+comment: "I am running out of imagination"], output - assert_match %r[t\.index\s+.+\s+comment: "\\\"Very important\\\" index that powers all the performance.\\nAnd it's fun!"], output - assert_match %r[t\.index\s+.+\s+name: "idx_obvious",\s+comment: "We need to see obvious comments"], output + if current_adapter?(:OracleAdapter) + assert_match %r[t\.integer\s+"rating",\s+precision: 38,\s+comment: "I am running out of imagination"], output + else + assert_match %r[t\.integer\s+"rating",\s+comment: "I am running out of imagination"], output + assert_match %r[t\.index\s+.+\s+comment: "\\\"Very important\\\" index that powers all the performance.\\nAnd it's fun!"], output + assert_match %r[t\.index\s+.+\s+name: "idx_obvious",\s+comment: "We need to see obvious comments"], output + end end def test_schema_dump_omits_blank_comments @@ -134,5 +142,27 @@ if ActiveRecord::Base.connection.supports_comments? assert_match %r[t\.string\s+"absent_comment"\n], output assert_no_match %r[t\.string\s+"absent_comment", comment:\n], output end + + def test_change_table_comment + @connection.change_table_comment :commenteds, "Edited table comment" + assert_equal "Edited table comment", @connection.table_comment("commenteds") + end + + def test_change_table_comment_to_nil + @connection.change_table_comment :commenteds, nil + assert_nil @connection.table_comment("commenteds") + end + + def test_change_column_comment + @connection.change_column_comment :commenteds, :name, "Edited column comment" + column = Commented.columns_hash["name"] + assert_equal "Edited column comment", column.comment + end + + def test_change_column_comment_to_nil + @connection.change_column_comment :commenteds, :name, nil + column = Commented.columns_hash["name"] + assert_nil column.comment + end end end diff --git a/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb index 64189381cb..72838ff56b 100644 --- a/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb +++ b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -43,11 +45,11 @@ module ActiveRecord # Make sure the pool marks the connection in use assert_equal @adapter, pool.connection - assert @adapter.in_use? + assert_predicate @adapter, :in_use? # Close should put the adapter back in the pool @adapter.close - assert_not @adapter.in_use? + assert_not_predicate @adapter, :in_use? assert_equal @adapter, pool.connection end diff --git a/activerecord/test/cases/connection_adapters/connection_handler_test.rb b/activerecord/test/cases/connection_adapters/connection_handler_test.rb index 681399c8bb..8c204a2692 100644 --- a/activerecord/test/cases/connection_adapters/connection_handler_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_handler_test.rb @@ -1,22 +1,43 @@ +# frozen_string_literal: true + require "cases/helper" +require "models/person" module ActiveRecord module ConnectionAdapters class ConnectionHandlerTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :people + def setup @handler = ConnectionHandler.new @spec_name = "primary" @pool = @handler.establish_connection(ActiveRecord::Base.configurations["arunit"]) end + def test_default_env_fall_back_to_default_env_when_rails_env_or_rack_env_is_empty_string + original_rails_env = ENV["RAILS_ENV"] + original_rack_env = ENV["RACK_ENV"] + ENV["RAILS_ENV"] = ENV["RACK_ENV"] = "" + + assert_equal "default_env", ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + ensure + ENV["RAILS_ENV"] = original_rails_env + ENV["RACK_ENV"] = original_rack_env + end + def test_establish_connection_uses_spec_name + old_config = ActiveRecord::Base.configurations config = { "readonly" => { "adapter" => "sqlite3" } } - resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(config) + ActiveRecord::Base.configurations = config + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(ActiveRecord::Base.configurations) spec = resolver.spec(:readonly) @handler.establish_connection(spec.to_hash) assert_not_nil @handler.retrieve_connection_pool("readonly") ensure + ActiveRecord::Base.configurations = old_config @handler.remove_connection("readonly") end @@ -53,6 +74,56 @@ module ActiveRecord ENV["RAILS_ENV"] = previous_env end + unless in_memory_db? + def test_establish_connection_using_3_level_config_defaults_to_default_env_primary_db + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + }, + "another_env" => { + "primary" => { "adapter" => "sqlite3", "database" => "db/another-primary.sqlite3" }, + "readonly" => { "adapter" => "sqlite3", "database" => "db/another-readonly.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.establish_connection + + assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ENV["RAILS_ENV"] = previous_env + ActiveRecord::Base.establish_connection(:arunit) + FileUtils.rm_rf "db" + end + + def test_establish_connection_using_2_level_config_defaults_to_default_env_primary_db + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "adapter" => "sqlite3", "database" => "db/primary.sqlite3" + }, + "another_env" => { + "adapter" => "sqlite3", "database" => "db/bad-primary.sqlite3" + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.establish_connection + + assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ENV["RAILS_ENV"] = previous_env + ActiveRecord::Base.establish_connection(:arunit) + FileUtils.rm_rf "db" + end + end + def test_establish_connection_using_two_level_configurations config = { "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } } @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config @@ -85,11 +156,11 @@ module ActiveRecord end def test_active_connections? - assert !@handler.active_connections? + assert_not_predicate @handler, :active_connections? assert @handler.retrieve_connection(@spec_name) - assert @handler.active_connections? + assert_predicate @handler, :active_connections? @handler.clear_active_connections! - assert !@handler.active_connections? + assert_not_predicate @handler, :active_connections? end def test_retrieve_connection_pool @@ -126,6 +197,75 @@ module ActiveRecord rd.close end + def test_forked_child_doesnt_mangle_parent_connection + object_id = ActiveRecord::Base.connection.object_id + assert_predicate ActiveRecord::Base.connection, :active? + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + pid = fork { + rd.close + if ActiveRecord::Base.connection.active? + wr.write Marshal.dump ActiveRecord::Base.connection.object_id + end + wr.close + + exit # allow finalizers to run + } + + wr.close + + Process.waitpid pid + assert_not_equal object_id, Marshal.load(rd.read) + rd.close + + assert_equal 3, ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people") + end + + unless in_memory_db? + def test_forked_child_recovers_from_disconnected_parent + object_id = ActiveRecord::Base.connection.object_id + assert_predicate ActiveRecord::Base.connection, :active? + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + outer_pid = fork { + ActiveRecord::Base.connection.disconnect! + + pid = fork { + rd.close + if ActiveRecord::Base.connection.active? + pair = [ActiveRecord::Base.connection.object_id, + ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people")] + wr.write Marshal.dump pair + end + wr.close + + exit # allow finalizers to run + } + + Process.waitpid pid + } + + wr.close + + Process.waitpid outer_pid + child_id, child_count = Marshal.load(rd.read) + + assert_not_equal object_id, child_id + rd.close + + assert_equal 3, child_count + + # Outer connection is unaffected + assert_equal 6, ActiveRecord::Base.connection.select_value("SELECT 2 * COUNT(*) FROM people") + end + end + def test_retrieve_connection_pool_copies_schema_cache_from_ancestor_pool @pool.schema_cache = @pool.connection.schema_cache @pool.schema_cache.add("posts") @@ -187,15 +327,15 @@ module ActiveRecord def test_a_class_using_custom_pool_and_switching_back_to_primary klass2 = Class.new(Base) { def self.name; "klass2"; end } - assert_equal klass2.connection.object_id, ActiveRecord::Base.connection.object_id + assert_same klass2.connection, ActiveRecord::Base.connection pool = klass2.establish_connection(ActiveRecord::Base.connection_pool.spec.config) - assert_equal klass2.connection.object_id, pool.connection.object_id - refute_equal klass2.connection.object_id, ActiveRecord::Base.connection.object_id + assert_same klass2.connection, pool.connection + assert_not_same klass2.connection, ActiveRecord::Base.connection klass2.remove_connection - assert_equal klass2.connection.object_id, ActiveRecord::Base.connection.object_id + assert_same klass2.connection, ActiveRecord::Base.connection end def test_connection_specification_name_should_fallback_to_parent @@ -210,8 +350,8 @@ module ActiveRecord def test_remove_connection_should_not_remove_parent klass2 = Class.new(Base) { def self.name; "klass2"; end } klass2.remove_connection - refute_nil ActiveRecord::Base.connection.object_id - assert_equal klass2.connection.object_id, ActiveRecord::Base.connection.object_id + assert_not_nil ActiveRecord::Base.connection + assert_same klass2.connection, ActiveRecord::Base.connection end end end diff --git a/activerecord/test/cases/connection_adapters/connection_specification_test.rb b/activerecord/test/cases/connection_adapters/connection_specification_test.rb index 10a3521c79..f81b73c344 100644 --- a/activerecord/test/cases/connection_adapters/connection_specification_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_specification_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb index 8faa67255d..06c1c51724 100644 --- a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -16,11 +18,14 @@ module ActiveRecord end def resolve_config(config) - ActiveRecord::ConnectionHandling::MergeAndResolveDefaultUrlConfig.new(config).resolve + configs = ActiveRecord::DatabaseConfigurations.new(config) + configs.to_h end def resolve_spec(spec, config) - ConnectionSpecification::Resolver.new(resolve_config(config)).resolve(spec) + configs = ActiveRecord::DatabaseConfigurations.new(config) + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs) + resolver.resolve(spec, spec) end def test_resolver_with_database_uri_and_current_env_symbol_key diff --git a/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb index 3657b8340d..02e76ce146 100644 --- a/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb +++ b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb @@ -1,13 +1,22 @@ +# frozen_string_literal: true + require "cases/helper" +require "support/connection_helper" if current_adapter?(:Mysql2Adapter) module ActiveRecord module ConnectionAdapters class MysqlTypeLookupTest < ActiveRecord::TestCase + include ConnectionHelper + setup do @connection = ActiveRecord::Base.connection end + def teardown + reset_connection + end + def test_boolean_types emulate_booleans(true) do assert_lookup_type :boolean, "tinyint(1)" @@ -47,7 +56,7 @@ if current_adapter?(:Mysql2Adapter) private def assert_lookup_type(type, lookup) - cast_type = @connection.type_map.lookup(lookup) + cast_type = @connection.send(:type_map).lookup(lookup) assert_equal type, cast_type.type end diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index 106323ccc9..67496381d1 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -20,8 +22,8 @@ module ActiveRecord new_cache = YAML.load(YAML.dump(@cache)) assert_no_queries do - assert_equal 11, new_cache.columns("posts").size - assert_equal 11, new_cache.columns_hash("posts").size + assert_equal 12, new_cache.columns("posts").size + assert_equal 12, new_cache.columns_hash("posts").size assert new_cache.data_sources("posts") assert_equal "id", new_cache.primary_keys("posts") end @@ -73,8 +75,8 @@ module ActiveRecord @cache = Marshal.load(Marshal.dump(@cache)) assert_no_queries do - assert_equal 11, @cache.columns("posts").size - assert_equal 11, @cache.columns_hash("posts").size + assert_equal 12, @cache.columns("posts").size + assert_equal 12, @cache.columns_hash("posts").size assert @cache.data_sources("posts") assert_equal "id", @cache.primary_keys("posts") end diff --git a/activerecord/test/cases/connection_adapters/type_lookup_test.rb b/activerecord/test/cases/connection_adapters/type_lookup_test.rb index a348c2d783..1c79d776f0 100644 --- a/activerecord/test/cases/connection_adapters/type_lookup_test.rb +++ b/activerecord/test/cases/connection_adapters/type_lookup_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strings for lookup @@ -80,11 +82,11 @@ unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strin end def test_bigint_limit - cast_type = @connection.type_map.lookup("bigint") + limit = @connection.send(:type_map).lookup("bigint").send(:_limit) if current_adapter?(:OracleAdapter) - assert_equal 19, cast_type.limit + assert_equal 19, limit else - assert_equal 8, cast_type.limit + assert_equal 8, limit end end @@ -98,7 +100,7 @@ unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strin { decimal: %w{decimal(2) decimal(2,0) numeric(2) numeric(2,0) number(2) number(2,0)} } end.each do |expected_type, types| types.each do |type| - cast_type = @connection.type_map.lookup(type) + cast_type = @connection.send(:type_map).lookup(type) assert_equal expected_type, cast_type.type assert_equal 2, cast_type.cast(2.1) @@ -109,7 +111,7 @@ unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strin private def assert_lookup_type(type, lookup) - cast_type = @connection.type_map.lookup(lookup) + cast_type = @connection.send(:type_map).lookup(lookup) assert_equal type, cast_type.type end end diff --git a/activerecord/test/cases/connection_management_test.rb b/activerecord/test/cases/connection_management_test.rb index d1e946d401..0941ee3309 100644 --- a/activerecord/test/cases/connection_management_test.rb +++ b/activerecord/test/cases/connection_management_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "rack" @@ -25,7 +27,7 @@ module ActiveRecord # make sure we have an active connection assert ActiveRecord::Base.connection - assert ActiveRecord::Base.connection_handler.active_connections? + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? end def test_app_delegation @@ -45,14 +47,14 @@ module ActiveRecord def test_connections_are_cleared_after_body_close _, _, body = @management.call(@env) body.close - assert !ActiveRecord::Base.connection_handler.active_connections? + assert_not_predicate ActiveRecord::Base.connection_handler, :active_connections? end def test_active_connections_are_not_cleared_on_body_close_during_transaction ActiveRecord::Base.transaction do _, _, body = @management.call(@env) body.close - assert ActiveRecord::Base.connection_handler.active_connections? + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? end end @@ -60,7 +62,7 @@ module ActiveRecord app = Class.new(App) { def call(env); raise NotImplementedError; end }.new explosive = middleware(app) assert_raises(NotImplementedError) { explosive.call(@env) } - assert !ActiveRecord::Base.connection_handler.active_connections? + assert_not_predicate ActiveRecord::Base.connection_handler, :active_connections? end def test_connections_not_closed_if_exception_inside_transaction @@ -68,14 +70,14 @@ module ActiveRecord app = Class.new(App) { def call(env); raise RuntimeError; end }.new explosive = middleware(app) assert_raises(RuntimeError) { explosive.call(@env) } - assert ActiveRecord::Base.connection_handler.active_connections? + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? end end test "doesn't clear active connections when running in a test case" do executor.wrap do @management.call(@env) - assert ActiveRecord::Base.connection_handler.active_connections? + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? end end @@ -83,7 +85,7 @@ module ActiveRecord body = Class.new(String) { def to_path; "/path"; end }.new app = lambda { |_| [200, {}, body] } response_body = middleware(app).call(@env)[2] - assert response_body.respond_to?(:to_path) + assert_respond_to response_body, :to_path assert_equal "/path", response_body.to_path end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 7e88c9cf7a..06869eeab0 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "concurrent/atomic/count_down_latch" @@ -33,12 +35,12 @@ module ActiveRecord def test_checkout_after_close connection = pool.connection - assert connection.in_use? + assert_predicate connection, :in_use? connection.close - assert !connection.in_use? + assert_not_predicate connection, :in_use? - assert pool.connection.in_use? + assert_predicate pool.connection, :in_use? end def test_released_connection_moves_between_threads @@ -78,18 +80,20 @@ module ActiveRecord end def test_active_connection_in_use - assert !pool.active_connection? + assert_not_predicate pool, :active_connection? main_thread = pool.connection - assert pool.active_connection? + assert_predicate pool, :active_connection? main_thread.close - assert !pool.active_connection? + assert_not_predicate pool, :active_connection? end def test_full_pool_exception - @pool.size.times { @pool.checkout } + @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout + @pool.size.times { assert @pool.checkout } + assert_raises(ConnectionTimeoutError) do @pool.checkout end @@ -107,6 +111,44 @@ module ActiveRecord assert_equal connection, t.join.value end + def test_full_pool_blocking_shares_load_interlock + @pool.instance_variable_set(:@size, 1) + + load_interlock_latch = Concurrent::CountDownLatch.new + connection_latch = Concurrent::CountDownLatch.new + + able_to_get_connection = false + able_to_load = false + + thread_with_load_interlock = Thread.new do + ActiveSupport::Dependencies.interlock.running do + load_interlock_latch.count_down + connection_latch.wait + + @pool.with_connection do + able_to_get_connection = true + end + end + end + + thread_with_last_connection = Thread.new do + @pool.with_connection do + connection_latch.count_down + load_interlock_latch.wait + + ActiveSupport::Dependencies.interlock.loading do + able_to_load = true + end + end + end + + thread_with_load_interlock.join + thread_with_last_connection.join + + assert able_to_get_connection + assert able_to_load + end + def test_removing_releases_latch cs = @pool.size.times.map { @pool.checkout } t = Thread.new { @pool.checkout } @@ -154,13 +196,103 @@ module ActiveRecord @pool.connections.each { |conn| conn.close if conn.in_use? } end + def test_idle_timeout_configuration + @pool.disconnect! + spec = ActiveRecord::Base.connection_pool.spec + spec.config.merge!(idle_timeout: "0.02") + @pool = ConnectionPool.new(spec) + idle_conn = @pool.checkout + @pool.checkin(idle_conn) + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 0.01 + ) + + @pool.flush + assert_equal 1, @pool.connections.length + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 0.02 + ) + + @pool.flush + assert_equal 0, @pool.connections.length + end + + def test_disable_flush + @pool.disconnect! + spec = ActiveRecord::Base.connection_pool.spec + spec.config.merge!(idle_timeout: -5) + @pool = ConnectionPool.new(spec) + idle_conn = @pool.checkout + @pool.checkin(idle_conn) + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 1 + ) + + @pool.flush + assert_equal 1, @pool.connections.length + end + + def test_flush + idle_conn = @pool.checkout + recent_conn = @pool.checkout + active_conn = @pool.checkout + + @pool.checkin idle_conn + @pool.checkin recent_conn + + assert_equal 3, @pool.connections.length + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 1000 + ) + + @pool.flush(30) + + assert_equal 2, @pool.connections.length + + assert_equal [recent_conn, active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__) + ensure + @pool.checkin active_conn + end + + def test_flush_bang + idle_conn = @pool.checkout + recent_conn = @pool.checkout + active_conn = @pool.checkout + _dead_conn = Thread.new { @pool.checkout }.join + + @pool.checkin idle_conn + @pool.checkin recent_conn + + assert_equal 4, @pool.connections.length + + def idle_conn.seconds_idle + 1000 + end + + @pool.flush! + + assert_equal 1, @pool.connections.length + + assert_equal [active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__) + ensure + @pool.checkin active_conn + end + def test_remove_connection conn = @pool.checkout - assert conn.in_use? + assert_predicate conn, :in_use? length = @pool.connections.length @pool.remove conn - assert conn.in_use? + assert_predicate conn, :in_use? assert_equal(length - 1, @pool.connections.length) ensure conn.close @@ -175,11 +307,11 @@ module ActiveRecord end def test_active_connection? - assert !@pool.active_connection? + assert_not_predicate @pool, :active_connection? assert @pool.connection - assert @pool.active_connection? + assert_predicate @pool, :active_connection? @pool.release_connection - assert !@pool.active_connection? + assert_not_predicate @pool, :active_connection? end def test_checkout_behaviour @@ -203,6 +335,14 @@ module ActiveRecord end.join end + def test_checkout_order_is_lifo + conn1 = @pool.checkout + conn2 = @pool.checkout + @pool.checkin conn1 + @pool.checkin conn2 + assert_equal [conn2, conn1], 2.times.map { @pool.checkout } + end + # The connection pool is "fair" if threads waiting for # connections receive them in the order in which they began # waiting. This ensures that we don't timeout one HTTP request @@ -412,6 +552,7 @@ module ActiveRecord end def test_non_bang_disconnect_and_clear_reloadable_connections_throw_exception_if_threads_dont_return_their_conns + Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout [:disconnect, :clear_reloadable_connections].each do |group_action_method| @pool.with_connection do |connection| @@ -420,6 +561,8 @@ module ActiveRecord end end end + ensure + Thread.report_on_exception = original_report_on_exception end def test_disconnect_and_clear_reloadable_connections_attempt_to_wait_for_threads_to_return_their_conns @@ -436,7 +579,7 @@ module ActiveRecord assert_nil timed_join_result # assert that since this is within default timeout our connection hasn't been forcefully taken away from us - assert @pool.active_connection? + assert_predicate @pool, :active_connection? end ensure thread.join if thread && !timed_join_result # clean up the other thread @@ -450,7 +593,7 @@ module ActiveRecord @pool.with_connection do |connection| Thread.new { @pool.send(group_action_method) }.join # assert connection has been forcefully taken away from us - assert_not @pool.active_connection? + assert_not_predicate @pool, :active_connection? # make a new connection for with_connection to clean up @pool.connection @@ -499,21 +642,8 @@ module ActiveRecord if failed second_thread_done.set - puts - puts ">>> test_disconnect_and_clear_reloadable_connections_are_able_to_preempt_other_waiting_threads / #{group_action_method}" - p [first_thread, second_thread] - p pool.stat - p pool.connections.map(&:owner) - first_thread.join(2) second_thread.join(2) - - puts "---" - p [first_thread, second_thread] - p pool.stat - p pool.connections.map(&:owner) - puts "<<<" - puts end first_thread.join(10) || raise("first_thread got stuck") diff --git a/activerecord/test/cases/connection_specification/resolver_test.rb b/activerecord/test/cases/connection_specification/resolver_test.rb index 13b5bae13c..72be14f507 100644 --- a/activerecord/test/cases/connection_specification/resolver_test.rb +++ b/activerecord/test/cases/connection_specification/resolver_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -5,11 +7,15 @@ module ActiveRecord class ConnectionSpecification class ResolverTest < ActiveRecord::TestCase def resolve(spec, config = {}) - Resolver.new(config).resolve(spec) + configs = ActiveRecord::DatabaseConfigurations.new(config) + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs) + resolver.resolve(spec, spec) end def spec(spec, config = {}) - Resolver.new(config).spec(spec) + configs = ActiveRecord::DatabaseConfigurations.new(config) + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs) + resolver.spec(spec) end def test_url_invalid_adapter @@ -17,7 +23,7 @@ module ActiveRecord spec "ridiculous://foo?encoding=utf8" end - assert_match "Could not load 'active_record/connection_adapters/ridiculous_adapter'", error.message + assert_match "Could not load the 'ridiculous' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", error.message end # The abstract adapter is used simply to bypass the bit of code that diff --git a/activerecord/test/cases/core_test.rb b/activerecord/test/cases/core_test.rb index 3735572898..6e7ae2efb4 100644 --- a/activerecord/test/cases/core_test.rb +++ b/activerecord/test/cases/core_test.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true + require "cases/helper" require "models/person" require "models/topic" require "pp" -require "active_support/core_ext/string/strip" class NonExistentTable < ActiveRecord::Base; end @@ -35,28 +36,28 @@ class CoreTest < ActiveRecord::TestCase def test_pretty_print_new topic = Topic.new - actual = "" + actual = "".dup PP.pp(topic, StringIO.new(actual)) - expected = <<-PRETTY.strip_heredoc - #<Topic:0xXXXXXX - id: nil, - title: nil, - author_name: nil, - author_email_address: "test@test.com", - written_on: nil, - bonus_time: nil, - last_read: nil, - content: nil, - important: nil, - approved: true, - replies_count: 0, - unique_replies_count: 0, - parent_id: nil, - parent_title: nil, - type: nil, - group: nil, - created_at: nil, - updated_at: nil> + expected = <<~PRETTY + #<Topic:0xXXXXXX + id: nil, + title: nil, + author_name: nil, + author_email_address: "test@test.com", + written_on: nil, + bonus_time: nil, + last_read: nil, + content: nil, + important: nil, + approved: true, + replies_count: 0, + unique_replies_count: 0, + parent_id: nil, + parent_title: nil, + type: nil, + group: nil, + created_at: nil, + updated_at: nil> PRETTY assert actual.start_with?(expected.split("XXXXXX").first) assert actual.end_with?(expected.split("XXXXXX").last) @@ -64,35 +65,35 @@ class CoreTest < ActiveRecord::TestCase def test_pretty_print_persisted topic = topics(:first) - actual = "" + actual = "".dup PP.pp(topic, StringIO.new(actual)) - expected = <<-PRETTY.strip_heredoc - #<Topic:0x\\w+ - id: 1, - title: "The First Topic", - author_name: "David", - author_email_address: "david@loudthinking.com", - written_on: 2003-07-16 14:28:11 UTC, - bonus_time: 2000-01-01 14:28:00 UTC, - last_read: Thu, 15 Apr 2004, - content: "Have a nice day", - important: nil, - approved: false, - replies_count: 1, - unique_replies_count: 0, - parent_id: nil, - parent_title: nil, - type: nil, - group: nil, - created_at: [^,]+, - updated_at: [^,>]+> + expected = <<~PRETTY + #<Topic:0x\\w+ + id: 1, + title: "The First Topic", + author_name: "David", + author_email_address: "david@loudthinking.com", + written_on: 2003-07-16 14:28:11 UTC, + bonus_time: 2000-01-01 14:28:00 UTC, + last_read: Thu, 15 Apr 2004, + content: "Have a nice day", + important: nil, + approved: false, + replies_count: 1, + unique_replies_count: 0, + parent_id: nil, + parent_title: nil, + type: nil, + group: nil, + created_at: [^,]+, + updated_at: [^,>]+> PRETTY assert_match(/\A#{expected}\z/, actual) end def test_pretty_print_uninitialized topic = Topic.allocate - actual = "" + actual = "".dup PP.pp(topic, StringIO.new(actual)) expected = "#<Topic:XXXXXX not initialized>\n" assert actual.start_with?(expected.split("XXXXXX").first) @@ -105,7 +106,7 @@ class CoreTest < ActiveRecord::TestCase "inspecting topic" end end - actual = "" + actual = "".dup PP.pp(subtopic.new, StringIO.new(actual)) assert_equal "inspecting topic\n", actual end diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb index 46d7526cc0..99d286dc52 100644 --- a/activerecord/test/cases/counter_cache_test.rb +++ b/activerecord/test/cases/counter_cache_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/car" @@ -278,38 +280,38 @@ class CounterCacheTest < ActiveRecord::TestCase end test "update counters with touch: :written_on" do - assert_touching @topic, :written_on do + assert_touching @topic, :updated_at, :written_on do Topic.update_counters(@topic.id, replies_count: -1, touch: :written_on) end end test "update multiple counters with touch: :written_on" do - assert_touching @topic, :written_on do + assert_touching @topic, :updated_at, :written_on do Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: :written_on) end end test "reset counters with touch: :written_on" do - assert_touching @topic, :written_on do + assert_touching @topic, :updated_at, :written_on do Topic.reset_counters(@topic.id, :replies, touch: :written_on) end end test "reset multiple counters with touch: :written_on" do - assert_touching @topic, :written_on do + assert_touching @topic, :updated_at, :written_on do Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1) Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: :written_on) end end test "increment counters with touch: :written_on" do - assert_touching @topic, :written_on do + assert_touching @topic, :updated_at, :written_on do Topic.increment_counter(:replies_count, @topic.id, touch: :written_on) end end test "decrement counters with touch: :written_on" do - assert_touching @topic, :written_on do + assert_touching @topic, :updated_at, :written_on do Topic.decrement_counter(:replies_count, @topic.id, touch: :written_on) end end diff --git a/activerecord/test/cases/custom_locking_test.rb b/activerecord/test/cases/custom_locking_test.rb index 15c8b684e4..f52b26e9ec 100644 --- a/activerecord/test/cases/custom_locking_test.rb +++ b/activerecord/test/cases/custom_locking_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/person" diff --git a/activerecord/test/cases/database_statements_test.rb b/activerecord/test/cases/database_statements_test.rb index 66035865be..1c934602ec 100644 --- a/activerecord/test/cases/database_statements_test.rb +++ b/activerecord/test/cases/database_statements_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class DatabaseStatementsTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/date_test.rb b/activerecord/test/cases/date_test.rb index 2edc0415cd..9f412cdb63 100644 --- a/activerecord/test/cases/date_test.rb +++ b/activerecord/test/cases/date_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" diff --git a/activerecord/test/cases/date_time_precision_test.rb b/activerecord/test/cases/date_time_precision_test.rb index e4a2f9ee17..e64a8372d0 100644 --- a/activerecord/test/cases/date_time_precision_test.rb +++ b/activerecord/test/cases/date_time_precision_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -25,6 +27,24 @@ if subsecond_precision_supported? assert_equal 5, Foo.columns_hash["updated_at"].precision end + def test_datetime_precision_is_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :created_at, :datetime, precision: 0 + @connection.add_column :foos, :updated_at, :datetime, precision: 6 + + time = ::Time.now.change(nsec: 123456789) + foo = Foo.new(created_at: time, updated_at: time) + + assert_equal 0, foo.created_at.nsec + assert_equal 123456000, foo.updated_at.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.created_at.nsec + assert_equal 123456000, foo.updated_at.nsec + end + def test_timestamps_helper_with_custom_precision @connection.create_table(:foos, force: true) do |t| t.timestamps precision: 4 diff --git a/activerecord/test/cases/date_time_test.rb b/activerecord/test/cases/date_time_test.rb index 3bc08f80ec..b5f35aff0e 100644 --- a/activerecord/test/cases/date_time_test.rb +++ b/activerecord/test/cases/date_time_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/task" @@ -52,10 +54,23 @@ class DateTimeTest < ActiveRecord::TestCase end def test_assign_in_local_timezone - now = DateTime.now + now = DateTime.civil(2017, 3, 1, 12, 0, 0) with_timezone_config default: :local do task = Task.new starting: now assert_equal now, task.starting end end + + def test_date_time_with_string_value_with_subsecond_precision + skip unless subsecond_precision_supported? + string_value = "2017-07-04 14:19:00.5" + topic = Topic.create(written_on: string_value) + assert_equal topic, Topic.find_by(written_on: string_value) + end + + def test_date_time_with_string_value_with_non_iso_format + string_value = "04/07/2017 2:19pm" + topic = Topic.create(written_on: string_value) + assert_equal topic, Topic.find_by(written_on: string_value) + end end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb index a6297673c9..0f957d41cf 100644 --- a/activerecord/test/cases/defaults_test.rb +++ b/activerecord/test/cases/defaults_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" require "models/default" @@ -7,7 +9,7 @@ class DefaultTest < ActiveRecord::TestCase def test_nil_defaults_for_not_null_columns %w(id name course_id).each do |name| column = Entrant.columns_hash[name] - assert !column.null, "#{name} column should be NOT NULL" + assert_not column.null, "#{name} column should be NOT NULL" assert_not column.default, "#{name} column should be DEFAULT 'nil'" end end @@ -51,7 +53,7 @@ class DefaultNumbersTest < ActiveRecord::TestCase def test_default_decimal_number record = DefaultNumber.new - assert_equal BigDecimal.new("2.78"), record.decimal_number + assert_equal BigDecimal("2.78"), record.decimal_number assert_equal "2.78", record.decimal_number_before_type_cast end end @@ -87,9 +89,14 @@ if current_adapter?(:PostgreSQLAdapter) test "schema dump includes default expression" do output = dump_table_schema("defaults") - assert_match %r/t\.date\s+"modified_date",\s+default: -> { "\('now'::text\)::date" }/, output + if ActiveRecord::Base.connection.postgresql_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 + assert_match %r/t\.date\s+"modified_date",\s+default: -> { "\('now'::text\)::date" }/, output + assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "now\(\)" }/, output + end assert_match %r/t\.date\s+"modified_date_function",\s+default: -> { "now\(\)" }/, output - assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "now\(\)" }/, output assert_match %r/t\.datetime\s+"modified_time_function",\s+default: -> { "now\(\)" }/, output end end @@ -99,21 +106,31 @@ if current_adapter?(:Mysql2Adapter) class MysqlDefaultExpressionTest < ActiveRecord::TestCase include SchemaDumpingHelper - if ActiveRecord::Base.connection.version >= "5.6.0" + if subsecond_precision_supported? test "schema dump datetime includes default expression" do output = dump_table_schema("datetime_defaults") - assert_match %r/t\.datetime\s+"modified_datetime",\s+default: -> { "CURRENT_TIMESTAMP" }/, output + assert_match %r/t\.datetime\s+"modified_datetime",\s+default: -> { "CURRENT_TIMESTAMP(?:\(\))?" }/i, output end - end - test "schema dump timestamp includes default expression" do - output = dump_table_schema("timestamp_defaults") - assert_match %r/t\.timestamp\s+"modified_timestamp",\s+default: -> { "CURRENT_TIMESTAMP" }/, output - end + test "schema dump datetime includes precise default expression" do + output = dump_table_schema("datetime_defaults") + assert_match %r/t\.datetime\s+"precise_datetime",.+default: -> { "CURRENT_TIMESTAMP\(6\)" }/i, output + end - test "schema dump timestamp without default expression" do - output = dump_table_schema("timestamp_defaults") - assert_match %r/t\.timestamp\s+"nullable_timestamp"$/, output + test "schema dump timestamp includes default expression" do + output = dump_table_schema("timestamp_defaults") + assert_match %r/t\.timestamp\s+"modified_timestamp",\s+default: -> { "CURRENT_TIMESTAMP(?:\(\))?" }/i, output + end + + test "schema dump timestamp includes precise default expression" do + output = dump_table_schema("timestamp_defaults") + assert_match %r/t\.timestamp\s+"precise_timestamp",.+default: -> { "CURRENT_TIMESTAMP\(6\)" }/i, output + end + + test "schema dump timestamp without default expression" do + output = dump_table_schema("timestamp_defaults") + assert_match %r/t\.timestamp\s+"nullable_timestamp"$/, output + end end end diff --git a/activerecord/test/cases/dirty_test.rb b/activerecord/test/cases/dirty_test.rb index c13a962e3e..b1ebd20d6b 100644 --- a/activerecord/test/cases/dirty_test.rb +++ b/activerecord/test/cases/dirty_test.rb @@ -1,13 +1,12 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" # For booleans require "models/pirate" # For timestamps require "models/parrot" require "models/person" # For optimistic locking require "models/aircraft" - -class NumericData < ActiveRecord::Base - self.table_name = "numeric_data" -end +require "models/numeric_data" class DirtyTest < ActiveRecord::TestCase include InTimeZone @@ -25,18 +24,18 @@ class DirtyTest < ActiveRecord::TestCase # Change catchphrase. pirate.catchphrase = "arrr" - assert pirate.catchphrase_changed? + assert_predicate pirate, :catchphrase_changed? assert_nil pirate.catchphrase_was assert_equal [nil, "arrr"], pirate.catchphrase_change # Saved - no changes. pirate.save! - assert !pirate.catchphrase_changed? + assert_not_predicate pirate, :catchphrase_changed? assert_nil pirate.catchphrase_change # Same value - no changes. pirate.catchphrase = "arrr" - assert !pirate.catchphrase_changed? + assert_not_predicate pirate, :catchphrase_changed? assert_nil pirate.catchphrase_change end @@ -47,23 +46,23 @@ class DirtyTest < ActiveRecord::TestCase # New record - no changes. pirate = target.new - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? assert_nil pirate.created_on_change # Saved - no changes. pirate.catchphrase = "arrrr, time zone!!" pirate.save! - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? assert_nil pirate.created_on_change # Change created_on. old_created_on = pirate.created_on pirate.created_on = Time.now - 1.day - assert pirate.created_on_changed? + assert_predicate pirate, :created_on_changed? assert_kind_of ActiveSupport::TimeWithZone, pirate.created_on_was assert_equal old_created_on, pirate.created_on_was pirate.created_on = old_created_on - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? end end @@ -74,7 +73,7 @@ class DirtyTest < ActiveRecord::TestCase pirate = target.create! pirate.created_on = pirate.created_on - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? end end @@ -87,19 +86,19 @@ class DirtyTest < ActiveRecord::TestCase # New record - no changes. pirate = target.new - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? assert_nil pirate.created_on_change # Saved - no changes. pirate.catchphrase = "arrrr, time zone!!" pirate.save! - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? assert_nil pirate.created_on_change # Change created_on. old_created_on = pirate.created_on pirate.created_on = Time.now + 1.day - assert pirate.created_on_changed? + assert_predicate pirate, :created_on_changed? # kind_of does not work because # ActiveSupport::TimeWithZone.name == 'Time' assert_instance_of Time, pirate.created_on_was @@ -114,19 +113,19 @@ class DirtyTest < ActiveRecord::TestCase # New record - no changes. pirate = target.new - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? assert_nil pirate.created_on_change # Saved - no changes. pirate.catchphrase = "arrrr, time zone!!" pirate.save! - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? assert_nil pirate.created_on_change # Change created_on. old_created_on = pirate.created_on pirate.created_on = Time.now + 1.day - assert pirate.created_on_changed? + assert_predicate pirate, :created_on_changed? # kind_of does not work because # ActiveSupport::TimeWithZone.name == 'Time' assert_instance_of Time, pirate.created_on_was @@ -138,11 +137,11 @@ class DirtyTest < ActiveRecord::TestCase # the actual attribute here is name, title is an # alias setup via alias_attribute parrot = Parrot.new - assert !parrot.title_changed? + assert_not_predicate parrot, :title_changed? assert_nil parrot.title_change parrot.name = "Sam" - assert parrot.title_changed? + assert_predicate parrot, :title_changed? assert_nil parrot.title_was assert_equal parrot.name_change, parrot.title_change end @@ -154,7 +153,7 @@ class DirtyTest < ActiveRecord::TestCase pirate.restore_catchphrase! assert_equal "Yar!", pirate.catchphrase assert_equal Hash.new, pirate.changes - assert !pirate.catchphrase_changed? + assert_not_predicate pirate, :catchphrase_changed? end def test_nullable_number_not_marked_as_changed_if_new_value_is_blank @@ -162,7 +161,7 @@ class DirtyTest < ActiveRecord::TestCase ["", nil].each do |value| pirate.parrot_id = value - assert !pirate.parrot_id_changed? + assert_not_predicate pirate, :parrot_id_changed? assert_nil pirate.parrot_id_change end end @@ -172,7 +171,7 @@ class DirtyTest < ActiveRecord::TestCase ["", nil].each do |value| numeric_data.bank_balance = value - assert !numeric_data.bank_balance_changed? + assert_not_predicate numeric_data, :bank_balance_changed? assert_nil numeric_data.bank_balance_change end end @@ -182,7 +181,7 @@ class DirtyTest < ActiveRecord::TestCase ["", nil].each do |value| numeric_data.temperature = value - assert !numeric_data.temperature_changed? + assert_not_predicate numeric_data, :temperature_changed? assert_nil numeric_data.temperature_change end end @@ -198,7 +197,7 @@ class DirtyTest < ActiveRecord::TestCase ["", nil].each do |value| topic.written_on = value assert_nil topic.written_on - assert !topic.written_on_changed? + assert_not_predicate topic, :written_on_changed? end end end @@ -209,10 +208,10 @@ class DirtyTest < ActiveRecord::TestCase pirate.catchphrase = "arrr" assert pirate.save! - assert !pirate.changed? + assert_not_predicate pirate, :changed? pirate.parrot_id = "0" - assert !pirate.changed? + assert_not_predicate pirate, :changed? end def test_integer_zero_to_integer_zero_not_marked_as_changed @@ -221,17 +220,17 @@ class DirtyTest < ActiveRecord::TestCase pirate.catchphrase = "arrr" assert pirate.save! - assert !pirate.changed? + assert_not_predicate pirate, :changed? pirate.parrot_id = 0 - assert !pirate.changed? + assert_not_predicate pirate, :changed? end def test_float_zero_to_string_zero_not_marked_as_changed data = NumericData.new temperature: 0.0 data.save! - assert_not data.changed? + assert_not_predicate data, :changed? data.temperature = "0" assert_empty data.changes @@ -252,38 +251,38 @@ class DirtyTest < ActiveRecord::TestCase # check the change from 1 to '' pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties") pirate.parrot_id = "" - assert pirate.parrot_id_changed? + assert_predicate pirate, :parrot_id_changed? assert_equal([1, nil], pirate.parrot_id_change) pirate.save # check the change from nil to 0 pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties") pirate.parrot_id = 0 - assert pirate.parrot_id_changed? + assert_predicate pirate, :parrot_id_changed? assert_equal([nil, 0], pirate.parrot_id_change) pirate.save # check the change from 0 to '' pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties") pirate.parrot_id = "" - assert pirate.parrot_id_changed? + assert_predicate pirate, :parrot_id_changed? assert_equal([0, nil], pirate.parrot_id_change) end def test_object_should_be_changed_if_any_attribute_is_changed pirate = Pirate.new - assert !pirate.changed? + assert_not_predicate pirate, :changed? assert_equal [], pirate.changed assert_equal Hash.new, pirate.changes pirate.catchphrase = "arrr" - assert pirate.changed? + assert_predicate pirate, :changed? assert_nil pirate.catchphrase_was assert_equal %w(catchphrase), pirate.changed assert_equal({ "catchphrase" => [nil, "arrr"] }, pirate.changes) pirate.save - assert !pirate.changed? + assert_not_predicate pirate, :changed? assert_equal [], pirate.changed assert_equal Hash.new, pirate.changes end @@ -291,42 +290,40 @@ class DirtyTest < ActiveRecord::TestCase def test_attribute_will_change! pirate = Pirate.create!(catchphrase: "arr") - assert !pirate.catchphrase_changed? + assert_not_predicate pirate, :catchphrase_changed? assert pirate.catchphrase_will_change! - assert pirate.catchphrase_changed? + assert_predicate pirate, :catchphrase_changed? assert_equal ["arr", "arr"], pirate.catchphrase_change pirate.catchphrase << " matey!" - assert pirate.catchphrase_changed? + assert_predicate pirate, :catchphrase_changed? assert_equal ["arr", "arr matey!"], pirate.catchphrase_change end def test_virtual_attribute_will_change - assert_deprecated do - parrot = Parrot.create!(name: "Ruby") - parrot.send(:attribute_will_change!, :cancel_save_from_callback) - assert parrot.has_changes_to_save? - end + parrot = Parrot.create!(name: "Ruby") + parrot.send(:attribute_will_change!, :cancel_save_from_callback) + assert_predicate parrot, :has_changes_to_save? end def test_association_assignment_changes_foreign_key pirate = Pirate.create!(catchphrase: "jarl") pirate.parrot = Parrot.create!(name: "Lorre") - assert pirate.changed? + assert_predicate pirate, :changed? assert_equal %w(parrot_id), pirate.changed end def test_attribute_should_be_compared_with_type_cast topic = Topic.new - assert topic.approved? - assert !topic.approved_changed? + assert_predicate topic, :approved? + assert_not_predicate topic, :approved_changed? # Coming from web form. params = { topic: { approved: 1 } } # In the controller. topic.attributes = params[:topic] - assert topic.approved? - assert !topic.approved_changed? + assert_predicate topic, :approved? + assert_not_predicate topic, :approved_changed? end def test_partial_update @@ -349,13 +346,14 @@ class DirtyTest < ActiveRecord::TestCase def test_partial_update_with_optimistic_locking person = Person.new(first_name: "foo") - old_lock_version = person.lock_version with_partial_writes Person, false do assert_queries(2) { 2.times { person.save! } } Person.where(id: person.id).update_all(first_name: "baz") end + old_lock_version = person.lock_version + with_partial_writes Person, true do assert_queries(0) { 2.times { person.save! } } assert_equal old_lock_version, person.reload.lock_version @@ -368,7 +366,7 @@ class DirtyTest < ActiveRecord::TestCase def test_changed_attributes_should_be_preserved_if_save_failure pirate = Pirate.new pirate.parrot_id = 1 - assert !pirate.save + assert_not pirate.save check_pirate_after_save_failure(pirate) pirate = Pirate.new @@ -380,9 +378,9 @@ class DirtyTest < ActiveRecord::TestCase def test_reload_should_clear_changed_attributes pirate = Pirate.create!(catchphrase: "shiver me timbers") pirate.catchphrase = "*hic*" - assert pirate.changed? + assert_predicate pirate, :changed? pirate.reload - assert !pirate.changed? + assert_not_predicate pirate, :changed? end def test_dup_objects_should_not_copy_dirty_flag_from_creator @@ -390,17 +388,17 @@ class DirtyTest < ActiveRecord::TestCase pirate_dup = pirate.dup pirate_dup.restore_catchphrase! pirate.catchphrase = "I love Rum" - assert pirate.catchphrase_changed? - assert !pirate_dup.catchphrase_changed? + assert_predicate pirate, :catchphrase_changed? + assert_not_predicate pirate_dup, :catchphrase_changed? end def test_reverted_changes_are_not_dirty phrase = "shiver me timbers" pirate = Pirate.create!(catchphrase: phrase) pirate.catchphrase = "*hic*" - assert pirate.changed? + assert_predicate pirate, :changed? pirate.catchphrase = phrase - assert !pirate.changed? + assert_not_predicate pirate, :changed? end def test_reverted_changes_are_not_dirty_after_multiple_changes @@ -408,40 +406,40 @@ class DirtyTest < ActiveRecord::TestCase pirate = Pirate.create!(catchphrase: phrase) 10.times do |i| pirate.catchphrase = "*hic*" * i - assert pirate.changed? + assert_predicate pirate, :changed? end - assert pirate.changed? + assert_predicate pirate, :changed? pirate.catchphrase = phrase - assert !pirate.changed? + assert_not_predicate pirate, :changed? end def test_reverted_changes_are_not_dirty_going_from_nil_to_value_and_back pirate = Pirate.create!(catchphrase: "Yar!") pirate.parrot_id = 1 - assert pirate.changed? - assert pirate.parrot_id_changed? - assert !pirate.catchphrase_changed? + assert_predicate pirate, :changed? + assert_predicate pirate, :parrot_id_changed? + assert_not_predicate pirate, :catchphrase_changed? pirate.parrot_id = nil - assert !pirate.changed? - assert !pirate.parrot_id_changed? - assert !pirate.catchphrase_changed? + assert_not_predicate pirate, :changed? + assert_not_predicate pirate, :parrot_id_changed? + assert_not_predicate pirate, :catchphrase_changed? end def test_save_should_store_serialized_attributes_even_with_partial_writes with_partial_writes(Topic) do topic = Topic.create!(content: { a: "a" }) - assert_not topic.changed? + assert_not_predicate topic, :changed? topic.content[:b] = "b" - assert topic.changed? + assert_predicate topic, :changed? topic.save! - assert_not topic.changed? + assert_not_predicate topic, :changed? assert_equal "b", topic.content[:b] topic.reload @@ -468,14 +466,21 @@ class DirtyTest < ActiveRecord::TestCase def test_save_should_not_save_serialized_attribute_with_partial_writes_if_not_present with_partial_writes(Topic) do - Topic.create!(author_name: "Bill", content: { a: "a" }) - topic = Topic.select("id, author_name").first + topic = Topic.create!(author_name: "Bill", content: { a: "a" }) + topic = Topic.select("id, author_name").find(topic.id) topic.update_columns author_name: "John" - topic = Topic.first - assert_not_nil topic.content + assert_not_nil topic.reload.content end end + def test_changes_to_save_should_not_mutate_array_of_hashes + topic = Topic.new(author_name: "Bill", content: [{ a: "a" }]) + + topic.changes_to_save + + assert_equal [{ a: "a" }], topic.content + end + def test_previous_changes # original values should be in previous_changes pirate = Pirate.new @@ -491,7 +496,7 @@ class DirtyTest < ActiveRecord::TestCase assert_not_nil pirate.previous_changes["updated_on"][1] assert_nil pirate.previous_changes["created_on"][0] assert_not_nil pirate.previous_changes["created_on"][1] - assert !pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("parrot_id") # original values should be in previous_changes pirate = Pirate.new @@ -505,7 +510,7 @@ class DirtyTest < ActiveRecord::TestCase assert_equal [nil, pirate.id], pirate.previous_changes["id"] assert_includes pirate.previous_changes, "updated_on" assert_includes pirate.previous_changes, "created_on" - assert !pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("parrot_id") pirate.catchphrase = "Yar!!" pirate.reload @@ -522,8 +527,8 @@ class DirtyTest < ActiveRecord::TestCase assert_equal ["arrr", "Me Maties!"], pirate.previous_changes["catchphrase"] assert_not_nil pirate.previous_changes["updated_on"][0] assert_not_nil pirate.previous_changes["updated_on"][1] - assert !pirate.previous_changes.key?("parrot_id") - assert !pirate.previous_changes.key?("created_on") + assert_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") pirate = Pirate.find_by_catchphrase("Me Maties!") @@ -536,8 +541,8 @@ class DirtyTest < ActiveRecord::TestCase assert_equal ["Me Maties!", "Thar She Blows!"], pirate.previous_changes["catchphrase"] assert_not_nil pirate.previous_changes["updated_on"][0] assert_not_nil pirate.previous_changes["updated_on"][1] - assert !pirate.previous_changes.key?("parrot_id") - assert !pirate.previous_changes.key?("created_on") + assert_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") travel(1.second) @@ -548,8 +553,8 @@ class DirtyTest < ActiveRecord::TestCase assert_equal ["Thar She Blows!", "Ahoy!"], pirate.previous_changes["catchphrase"] assert_not_nil pirate.previous_changes["updated_on"][0] assert_not_nil pirate.previous_changes["updated_on"][1] - assert !pirate.previous_changes.key?("parrot_id") - assert !pirate.previous_changes.key?("created_on") + assert_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") travel(1.second) @@ -560,10 +565,8 @@ class DirtyTest < ActiveRecord::TestCase assert_equal ["Ahoy!", "Ninjas suck!"], pirate.previous_changes["catchphrase"] assert_not_nil pirate.previous_changes["updated_on"][0] assert_not_nil pirate.previous_changes["updated_on"][1] - assert !pirate.previous_changes.key?("parrot_id") - assert !pirate.previous_changes.key?("created_on") - ensure - travel_back + assert_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") end class Testings < ActiveRecord::Base; end @@ -599,7 +602,7 @@ class DirtyTest < ActiveRecord::TestCase pirate = Pirate.create!(catchphrase: "rrrr", created_on: time_in_paris) pirate.created_on = pirate.created_on.in_time_zone("Tokyo").to_s - assert !pirate.created_on_changed? + assert_not_predicate pirate, :created_on_changed? end test "partial insert" do @@ -630,7 +633,7 @@ class DirtyTest < ActiveRecord::TestCase pirate = Pirate.create!(catchphrase: "arrrr") pirate.catchphrase << " matey!" - assert pirate.catchphrase_changed? + assert_predicate pirate, :catchphrase_changed? expected_changes = { "catchphrase" => ["arrrr", "arrrr matey!"] } @@ -644,7 +647,7 @@ class DirtyTest < ActiveRecord::TestCase pirate.reload assert_equal "arrrr matey!", pirate.catchphrase - assert_not pirate.changed? + assert_not_predicate pirate, :changed? end test "in place mutation for binary" do @@ -655,19 +658,60 @@ class DirtyTest < ActiveRecord::TestCase binary = klass.create!(data: "\\\\foo") - assert_not binary.changed? + assert_not_predicate binary, :changed? binary.data = binary.data.dup - assert_not binary.changed? + assert_not_predicate binary, :changed? binary = klass.last - assert_not binary.changed? + assert_not_predicate binary, :changed? binary.data << "bar" - assert binary.changed? + assert_predicate binary, :changed? + end + + test "changes is correct for subclass" do + foo = Class.new(Pirate) do + def catchphrase + super.upcase + end + end + + pirate = foo.create!(catchphrase: "arrrr") + + new_catchphrase = "arrrr matey!" + + pirate.catchphrase = new_catchphrase + assert_predicate pirate, :catchphrase_changed? + + expected_changes = { + "catchphrase" => ["arrrr", new_catchphrase] + } + + assert_equal new_catchphrase.upcase, pirate.catchphrase + assert_equal expected_changes, pirate.changes + end + + test "changes is correct if override attribute reader" do + pirate = Pirate.create!(catchphrase: "arrrr") + def pirate.catchphrase + super.upcase + end + + new_catchphrase = "arrrr matey!" + + pirate.catchphrase = new_catchphrase + assert_predicate pirate, :catchphrase_changed? + + expected_changes = { + "catchphrase" => ["arrrr", new_catchphrase] + } + + assert_equal new_catchphrase.upcase, pirate.catchphrase + assert_equal expected_changes, pirate.changes end test "attribute_changed? doesn't compute in-place changes for unrelated attributes" do @@ -682,7 +726,7 @@ class DirtyTest < ActiveRecord::TestCase end model = klass.new(first_name: "Jim") - assert model.first_name_changed? + assert_predicate model, :first_name_changed? end test "attribute_will_change! doesn't try to save non-persistable attributes" do @@ -694,10 +738,28 @@ class DirtyTest < ActiveRecord::TestCase record = klass.new(first_name: "Sean") record.non_persisted_attribute_will_change! - assert record.non_persisted_attribute_changed? + assert_predicate record, :non_persisted_attribute_changed? assert record.save end + test "virtual attributes are not written with partial_writes off" do + with_partial_writes(ActiveRecord::Base, false) do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + attribute :non_persisted_attribute, :string + end + + record = klass.new(first_name: "Sean") + record.non_persisted_attribute_will_change! + + assert record.save + + record.non_persisted_attribute_will_change! + + assert record.save + end + end + test "mutating and then assigning doesn't remove the change" do pirate = Pirate.create!(catchphrase: "arrrr") pirate.catchphrase << " matey!" @@ -724,26 +786,33 @@ class DirtyTest < ActiveRecord::TestCase test "attributes assigned but not selected are dirty" do person = Person.select(:id).first - refute person.changed? + assert_not_predicate person, :changed? person.first_name = "Sean" - assert person.changed? + assert_predicate person, :changed? person.first_name = nil - assert person.changed? + assert_predicate person, :changed? + end + + test "attributes not selected are still missing after save" do + person = Person.select(:id).first + assert_raises(ActiveModel::MissingAttributeError) { person.first_name } + assert person.save # calls forget_attribute_assignments + assert_raises(ActiveModel::MissingAttributeError) { person.first_name } end test "saved_change_to_attribute? returns whether a change occurred in the last save" do person = Person.create!(first_name: "Sean") - assert person.saved_change_to_first_name? - refute person.saved_change_to_gender? + assert_predicate person, :saved_change_to_first_name? + assert_not_predicate person, :saved_change_to_gender? assert person.saved_change_to_first_name?(from: nil, to: "Sean") assert person.saved_change_to_first_name?(from: nil) assert person.saved_change_to_first_name?(to: "Sean") - refute person.saved_change_to_first_name?(from: "Jim", to: "Sean") - refute person.saved_change_to_first_name?(from: "Jim") - refute person.saved_change_to_first_name?(to: "Jim") + assert_not person.saved_change_to_first_name?(from: "Jim", to: "Sean") + assert_not person.saved_change_to_first_name?(from: "Jim") + assert_not person.saved_change_to_first_name?(to: "Jim") end test "saved_change_to_attribute returns the change that occurred in the last save" do @@ -778,11 +847,11 @@ class DirtyTest < ActiveRecord::TestCase test "saved_changes? returns whether the last call to save changed anything" do person = Person.create!(first_name: "Sean") - assert person.saved_changes? + assert_predicate person, :saved_changes? person.save - refute person.saved_changes? + assert_not_predicate person, :saved_changes? end test "saved_changes returns a hash of all the changes that occurred" do @@ -800,20 +869,39 @@ class DirtyTest < ActiveRecord::TestCase assert_equal %w(first_name lock_version updated_at).sort, person.saved_changes.keys.sort end - test "changed? in after callbacks returns true but is deprecated" do + test "changed? in after callbacks returns false" do klass = Class.new(ActiveRecord::Base) do self.table_name = "people" after_save do - ActiveSupport::Deprecation.silence do - raise "changed? should be true" unless changed? - end + raise "changed? should be false" if changed? + raise "has_changes_to_save? should be false" if has_changes_to_save? + raise "saved_changes? should be true" unless saved_changes? + raise "id_in_database should not be nil" if id_in_database.nil? + end + end + + person = klass.create!(first_name: "Sean") + assert_not_predicate person, :changed? + end + + test "changed? in around callbacks after yield returns false" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + + around_create :check_around + + def check_around + yield + raise "changed? should be false" if changed? raise "has_changes_to_save? should be false" if has_changes_to_save? + raise "saved_changes? should be true" unless saved_changes? + raise "id_in_database should not be nil" if id_in_database.nil? end end person = klass.create!(first_name: "Sean") - refute person.changed? + assert_not_predicate person, :changed? end private @@ -826,8 +914,8 @@ class DirtyTest < ActiveRecord::TestCase end def check_pirate_after_save_failure(pirate) - assert pirate.changed? - assert pirate.parrot_id_changed? + assert_predicate pirate, :changed? + assert_predicate pirate, :parrot_id_changed? assert_equal %w(parrot_id), pirate.changed assert_nil pirate.parrot_id_was end diff --git a/activerecord/test/cases/disconnected_test.rb b/activerecord/test/cases/disconnected_test.rb index c25089a420..533665d0f4 100644 --- a/activerecord/test/cases/disconnected_test.rb +++ b/activerecord/test/cases/disconnected_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class TestRecord < ActiveRecord::Base diff --git a/activerecord/test/cases/dup_test.rb b/activerecord/test/cases/dup_test.rb index 3821e0c949..a2efbf89f9 100644 --- a/activerecord/test/cases/dup_test.rb +++ b/activerecord/test/cases/dup_test.rb @@ -1,20 +1,23 @@ +# frozen_string_literal: true + require "cases/helper" require "models/reply" require "models/topic" +require "models/movie" module ActiveRecord class DupTest < ActiveRecord::TestCase fixtures :topics def test_dup - assert !Topic.new.freeze.dup.frozen? + assert_not_predicate Topic.new.freeze.dup, :frozen? end def test_not_readonly topic = Topic.first duped = topic.dup - assert !duped.readonly?, "should not be readonly" + assert_not duped.readonly?, "should not be readonly" end def test_is_readonly @@ -29,7 +32,7 @@ module ActiveRecord topic = Topic.first duped = topic.dup - assert !duped.persisted?, "topic not persisted" + assert_not duped.persisted?, "topic not persisted" assert duped.new_record?, "topic is new" end @@ -38,7 +41,7 @@ module ActiveRecord topic.destroy duped = topic.dup - assert_not duped.destroyed? + assert_not_predicate duped, :destroyed? end def test_dup_has_no_id @@ -60,10 +63,10 @@ module ActiveRecord topic.attributes = dbtopic.attributes.except("id") - #duped has no timestamp values + # duped has no timestamp values duped = dbtopic.dup - #clear topic timestamp values + # clear topic timestamp values topic.send(:clear_timestamp_attributes) assert_equal topic.changes, duped.changes @@ -98,7 +101,7 @@ module ActiveRecord # temporary change to the topic object topic.updated_at -= 3.days - #dup should not preserve the timestamps if present + # dup should not preserve the timestamps if present new_topic = topic.dup assert_nil new_topic.updated_at assert_nil new_topic.created_at @@ -124,12 +127,12 @@ module ActiveRecord duped = topic.dup duped.title = nil - assert duped.invalid? + assert_predicate duped, :invalid? topic.title = nil duped.title = "Mathematics" - assert topic.invalid? - assert duped.valid? + assert_predicate topic, :invalid? + assert_predicate duped, :valid? end end @@ -137,12 +140,14 @@ module ActiveRecord prev_default_scopes = Topic.default_scopes Topic.default_scopes = [proc { Topic.where(approved: true) }] topic = Topic.new(approved: false) - assert !topic.dup.approved?, "should not be overridden by default scopes" + assert_not topic.dup.approved?, "should not be overridden by default scopes" ensure Topic.default_scopes = prev_default_scopes end def test_dup_without_primary_key + skip if current_adapter?(:OracleAdapter) + klass = Class.new(ActiveRecord::Base) do self.table_name = "parrots_pirates" end @@ -153,5 +158,20 @@ module ActiveRecord record.dup end end + + def test_dup_record_not_persisted_after_rollback_transaction + movie = Movie.new(name: "test") + + assert_raises(ActiveRecord::RecordInvalid) do + Movie.transaction do + movie.save! + duped = movie.dup + duped.name = nil + duped.save! + end + end + + assert_not movie.persisted? + end end end diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb index b7641fcf32..d5a1d11e12 100644 --- a/activerecord/test/cases/enum_test.rb +++ b/activerecord/test/cases/enum_test.rb @@ -1,24 +1,27 @@ +# frozen_string_literal: true + require "cases/helper" +require "models/author" require "models/book" class EnumTest < ActiveRecord::TestCase - fixtures :books + fixtures :books, :authors, :author_addresses setup do @book = books(:awdr) end test "query state by predicate" do - assert @book.published? - assert_not @book.written? - assert_not @book.proposed? + assert_predicate @book, :published? + assert_not_predicate @book, :written? + assert_not_predicate @book, :proposed? - assert @book.read? - assert @book.in_english? - assert @book.author_visibility_visible? - assert @book.illustrator_visibility_visible? - assert @book.with_medium_font_size? - assert @book.medium_to_read? + assert_predicate @book, :read? + assert_predicate @book, :in_english? + assert_predicate @book, :author_visibility_visible? + assert_predicate @book, :illustrator_visibility_visible? + assert_predicate @book, :with_medium_font_size? + assert_predicate @book, :medium_to_read? end test "query state with strings" do @@ -37,6 +40,8 @@ class EnumTest < ActiveRecord::TestCase assert_equal @book, Book.author_visibility_visible.first assert_equal @book, Book.illustrator_visibility_visible.first assert_equal @book, Book.medium_to_read.first + assert_equal books(:ddd), Book.forgotten.first + assert_equal books(:rfr), authors(:david).unpublished_books.first end test "find via where with values" do @@ -57,6 +62,7 @@ class EnumTest < ActiveRecord::TestCase assert_not_equal @book, Book.where(status: [:written]).first assert_not_equal @book, Book.where.not(status: :published).first assert_equal @book, Book.where.not(status: :written).first + assert_equal books(:ddd), Book.where(read_status: :forgotten).first end test "find via where with strings" do @@ -66,49 +72,50 @@ class EnumTest < ActiveRecord::TestCase assert_not_equal @book, Book.where(status: ["written"]).first assert_not_equal @book, Book.where.not(status: "published").first assert_equal @book, Book.where.not(status: "written").first + assert_equal books(:ddd), Book.where(read_status: "forgotten").first end test "build from scope" do - assert Book.written.build.written? - assert_not Book.written.build.proposed? + assert_predicate Book.written.build, :written? + assert_not_predicate Book.written.build, :proposed? end test "build from where" do - assert Book.where(status: Book.statuses[:written]).build.written? - assert_not Book.where(status: Book.statuses[:written]).build.proposed? - assert Book.where(status: :written).build.written? - assert_not Book.where(status: :written).build.proposed? - assert Book.where(status: "written").build.written? - assert_not Book.where(status: "written").build.proposed? + assert_predicate Book.where(status: Book.statuses[:written]).build, :written? + assert_not_predicate Book.where(status: Book.statuses[:written]).build, :proposed? + assert_predicate Book.where(status: :written).build, :written? + assert_not_predicate Book.where(status: :written).build, :proposed? + assert_predicate Book.where(status: "written").build, :written? + assert_not_predicate Book.where(status: "written").build, :proposed? end test "update by declaration" do @book.written! - assert @book.written? + assert_predicate @book, :written? @book.in_english! - assert @book.in_english? + assert_predicate @book, :in_english? @book.author_visibility_visible! - assert @book.author_visibility_visible? + assert_predicate @book, :author_visibility_visible? end test "update by setter" do @book.update! status: :written - assert @book.written? + assert_predicate @book, :written? end test "enum methods are overwritable" do assert_equal "do publish work...", @book.published! - assert @book.published? + assert_predicate @book, :published? end test "direct assignment" do @book.status = :written - assert @book.written? + assert_predicate @book, :written? end test "assign string value" do @book.status = "written" - assert @book.written? + assert_predicate @book, :written? end test "enum changed attributes" do @@ -235,25 +242,27 @@ class EnumTest < ActiveRecord::TestCase end test "building new objects with enum scopes" do - assert Book.written.build.written? - assert Book.read.build.read? - assert Book.in_spanish.build.in_spanish? - assert Book.illustrator_visibility_invisible.build.illustrator_visibility_invisible? + assert_predicate Book.written.build, :written? + assert_predicate Book.read.build, :read? + assert_predicate Book.in_spanish.build, :in_spanish? + assert_predicate Book.illustrator_visibility_invisible.build, :illustrator_visibility_invisible? end test "creating new objects with enum scopes" do - assert Book.written.create.written? - assert Book.read.create.read? - assert Book.in_spanish.create.in_spanish? - assert Book.illustrator_visibility_invisible.create.illustrator_visibility_invisible? + assert_predicate Book.written.create, :written? + assert_predicate Book.read.create, :read? + assert_predicate Book.in_spanish.create, :in_spanish? + assert_predicate Book.illustrator_visibility_invisible.create, :illustrator_visibility_invisible? end - test "_before_type_cast returns the enum label (required for form fields)" do - if @book.status_came_from_user? - assert_equal "published", @book.status_before_type_cast - else - assert_equal "published", @book.status - end + test "_before_type_cast" do + assert_equal 2, @book.status_before_type_cast + assert_equal "published", @book.status + + @book.status = "published" + + assert_equal "published", @book.status_before_type_cast + assert_equal "published", @book.status end test "reserved enum names" do @@ -299,6 +308,24 @@ class EnumTest < ActiveRecord::TestCase end end + test "reserved enum values for relation" do + relation_method_samples = [ + :records, + :to_ary, + :scope_for_create + ] + + relation_method_samples.each do |value| + e = assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum category: [:other, value] + end + end + assert_match(/You tried to define an enum named .* on the model/, e.message) + end + end + test "overriding enum method should not raise" do assert_nothing_raised do Class.new(ActiveRecord::Base) do @@ -328,9 +355,9 @@ class EnumTest < ActiveRecord::TestCase klass.delete_all klass.create!(status: "proposed") book = klass.new(status: "written") - assert book.valid? + assert_predicate book, :valid? book.status = "proposed" - assert_not book.valid? + assert_not_predicate book, :valid? end test "validate inclusion of value in array" do @@ -341,9 +368,9 @@ class EnumTest < ActiveRecord::TestCase end klass.delete_all invalid_book = klass.new(status: "proposed") - assert_not invalid_book.valid? + assert_not_predicate invalid_book, :valid? valid_book = klass.new(status: "written") - assert valid_book.valid? + assert_predicate valid_book, :valid? end test "enums are distinct per class" do @@ -390,10 +417,10 @@ class EnumTest < ActiveRecord::TestCase end book1 = klass.proposed.create! - assert book1.proposed? + assert_predicate book1, :proposed? book2 = klass.single.create! - assert book2.single? + assert_predicate book2, :single? end test "enum with alias_attribute" do @@ -404,62 +431,62 @@ class EnumTest < ActiveRecord::TestCase end book = klass.proposed.create! - assert book.proposed? + assert_predicate book, :proposed? assert_equal "proposed", book.aliased_status book = klass.find(book.id) - assert book.proposed? + assert_predicate book, :proposed? assert_equal "proposed", book.aliased_status end test "query state by predicate with prefix" do - assert @book.author_visibility_visible? - assert_not @book.author_visibility_invisible? - assert @book.illustrator_visibility_visible? - assert_not @book.illustrator_visibility_invisible? + assert_predicate @book, :author_visibility_visible? + assert_not_predicate @book, :author_visibility_invisible? + assert_predicate @book, :illustrator_visibility_visible? + assert_not_predicate @book, :illustrator_visibility_invisible? end test "query state by predicate with custom prefix" do - assert @book.in_english? - assert_not @book.in_spanish? - assert_not @book.in_french? + assert_predicate @book, :in_english? + assert_not_predicate @book, :in_spanish? + assert_not_predicate @book, :in_french? end test "query state by predicate with custom suffix" do - assert @book.medium_to_read? - assert_not @book.easy_to_read? - assert_not @book.hard_to_read? + assert_predicate @book, :medium_to_read? + assert_not_predicate @book, :easy_to_read? + assert_not_predicate @book, :hard_to_read? end test "enum methods with custom suffix defined" do - assert @book.class.respond_to?(:easy_to_read) - assert @book.class.respond_to?(:medium_to_read) - assert @book.class.respond_to?(:hard_to_read) + assert_respond_to @book.class, :easy_to_read + assert_respond_to @book.class, :medium_to_read + assert_respond_to @book.class, :hard_to_read - assert @book.respond_to?(:easy_to_read?) - assert @book.respond_to?(:medium_to_read?) - assert @book.respond_to?(:hard_to_read?) + assert_respond_to @book, :easy_to_read? + assert_respond_to @book, :medium_to_read? + assert_respond_to @book, :hard_to_read? - assert @book.respond_to?(:easy_to_read!) - assert @book.respond_to?(:medium_to_read!) - assert @book.respond_to?(:hard_to_read!) + assert_respond_to @book, :easy_to_read! + assert_respond_to @book, :medium_to_read! + assert_respond_to @book, :hard_to_read! end test "update enum attributes with custom suffix" do @book.medium_to_read! - assert_not @book.easy_to_read? - assert @book.medium_to_read? - assert_not @book.hard_to_read? + assert_not_predicate @book, :easy_to_read? + assert_predicate @book, :medium_to_read? + assert_not_predicate @book, :hard_to_read? @book.easy_to_read! - assert @book.easy_to_read? - assert_not @book.medium_to_read? - assert_not @book.hard_to_read? + assert_predicate @book, :easy_to_read? + assert_not_predicate @book, :medium_to_read? + assert_not_predicate @book, :hard_to_read? @book.hard_to_read! - assert_not @book.easy_to_read? - assert_not @book.medium_to_read? - assert @book.hard_to_read? + assert_not_predicate @book, :easy_to_read? + assert_not_predicate @book, :medium_to_read? + assert_predicate @book, :hard_to_read? end test "uses default status when no status is provided in fixtures" do @@ -470,12 +497,12 @@ class EnumTest < ActiveRecord::TestCase test "uses default value from database on initialization" do book = Book.new - assert book.proposed? + assert_predicate book, :proposed? end test "uses default value from database on initialization when using custom mapping" do book = Book.new - assert book.hard? + assert_predicate book, :hard? end test "data type of Enum type" do diff --git a/activerecord/test/cases/errors_test.rb b/activerecord/test/cases/errors_test.rb index 73feb831d0..b90e6a66c5 100644 --- a/activerecord/test/cases/errors_test.rb +++ b/activerecord/test/cases/errors_test.rb @@ -1,4 +1,6 @@ -require_relative "../cases/helper" +# frozen_string_literal: true + +require "cases/helper" class ErrorsTest < ActiveRecord::TestCase def test_can_be_instantiated_with_no_args diff --git a/activerecord/test/cases/explain_subscriber_test.rb b/activerecord/test/cases/explain_subscriber_test.rb index ca87e04012..79a0630193 100644 --- a/activerecord/test/cases/explain_subscriber_test.rb +++ b/activerecord/test/cases/explain_subscriber_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_record/explain_subscriber" require "active_record/explain_registry" @@ -13,20 +15,20 @@ if ActiveRecord::Base.connection.supports_explain? def test_collects_nothing_if_the_payload_has_an_exception SUBSCRIBER.finish(nil, nil, exception: Exception.new) - assert queries.empty? + assert_empty queries end def test_collects_nothing_for_ignored_payloads ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.each do |ip| SUBSCRIBER.finish(nil, nil, name: ip) end - assert queries.empty? + assert_empty queries end def test_collects_nothing_if_collect_is_false ActiveRecord::ExplainRegistry.collect = false SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "select 1 from users", binds: [1, 2]) - assert queries.empty? + assert_empty queries end def test_collects_pairs_of_queries_and_binds @@ -38,14 +40,14 @@ if ActiveRecord::Base.connection.supports_explain? assert_equal binds, queries[0][1] end - def test_collects_nothing_if_the_statement_is_not_whitelisted + def test_collects_nothing_if_the_statement_is_not_explainable SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "SHOW max_identifier_length") - assert queries.empty? + assert_empty queries end def test_collects_nothing_if_the_statement_is_only_partially_matched SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "select_db yo_mama") - assert queries.empty? + assert_empty queries end def test_collects_cte_queries diff --git a/activerecord/test/cases/explain_test.rb b/activerecord/test/cases/explain_test.rb index 86fe90ae51..a0e75f4e89 100644 --- a/activerecord/test/cases/explain_test.rb +++ b/activerecord/test/cases/explain_test.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + require "cases/helper" require "models/car" -require "active_support/core_ext/string/strip" if ActiveRecord::Base.connection.supports_explain? class ExplainTest < ActiveRecord::TestCase @@ -51,7 +52,7 @@ if ActiveRecord::Base.connection.supports_explain? queries = sqls.zip(binds) stub_explain_for_query_plans(["query plan foo\n", "query plan bar\n"]) do - expected = <<-SQL.strip_heredoc + expected = <<~SQL EXPLAIN for: #{sqls[0]} [["wadus", 1]] query plan foo diff --git a/activerecord/test/cases/filter_attributes_test.rb b/activerecord/test/cases/filter_attributes_test.rb new file mode 100644 index 0000000000..f88cecfe2b --- /dev/null +++ b/activerecord/test/cases/filter_attributes_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/admin" +require "models/admin/user" +require "models/admin/account" +require "pp" + +class FilterAttributesTest < ActiveRecord::TestCase + fixtures :"admin/users", :"admin/accounts" + + setup do + ActiveRecord::Base.filter_attributes = [:name] + end + + teardown do + ActiveRecord::Base.filter_attributes = [] + end + + test "filter_attributes" do + Admin::User.all.each do |user| + assert_includes user.inspect, "name: [FILTERED]" + assert_equal 1, user.inspect.scan("[FILTERED]").length + end + + Admin::Account.all.each do |account| + assert_includes account.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + end + + test "filter_attributes could be overwritten by models" do + Admin::Account.all.each do |account| + assert_includes account.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + + Admin::Account.filter_attributes = [] + + # Above changes should not impact other models + Admin::User.all.each do |user| + assert_includes user.inspect, "name: [FILTERED]" + assert_equal 1, user.inspect.scan("[FILTERED]").length + end + + Admin::Account.all.each do |account| + assert_not_includes account.inspect, "name: [FILTERED]" + assert_equal 0, account.inspect.scan("[FILTERED]").length + end + + Admin::Account.filter_attributes = [:name] + end + + test "filter_attributes should not filter nil value" do + account = Admin::Account.new + + assert_includes account.inspect, "name: nil" + assert_not_includes account.inspect, "name: [FILTERED]" + assert_equal 0, account.inspect.scan("[FILTERED]").length + end + + test "filter_attributes on pretty_print" do + user = admin_users(:david) + actual = "".dup + PP.pp(user, StringIO.new(actual)) + + assert_includes actual, "name: [FILTERED]" + assert_equal 1, actual.scan("[FILTERED]").length + end + + test "filter_attributes on pretty_print should not filter nil value" do + user = Admin::User.new + actual = "".dup + PP.pp(user, StringIO.new(actual)) + + assert_includes actual, "name: nil" + assert_not_includes actual, "name: [FILTERED]" + assert_equal 0, actual.scan("[FILTERED]").length + end +end diff --git a/activerecord/test/cases/finder_respond_to_test.rb b/activerecord/test/cases/finder_respond_to_test.rb index 3eaa993d45..59af4e6961 100644 --- a/activerecord/test/cases/finder_respond_to_test.rb +++ b/activerecord/test/cases/finder_respond_to_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" @@ -6,7 +8,7 @@ class FinderRespondToTest < ActiveRecord::TestCase def test_should_preserve_normal_respond_to_behaviour_on_base assert_respond_to ActiveRecord::Base, :new - assert !ActiveRecord::Base.respond_to?(:find_by_something) + assert_not_respond_to ActiveRecord::Base, :find_by_something end def test_should_preserve_normal_respond_to_behaviour_and_respond_to_newly_added_method @@ -41,14 +43,14 @@ class FinderRespondToTest < ActiveRecord::TestCase end def test_should_not_respond_to_find_by_one_missing_attribute - assert !Topic.respond_to?(:find_by_undertitle) + assert_not_respond_to Topic, :find_by_undertitle end def test_should_not_respond_to_find_by_invalid_method_syntax - assert !Topic.respond_to?(:fail_to_find_by_title) - assert !Topic.respond_to?(:find_by_title?) - assert !Topic.respond_to?(:fail_to_find_or_create_by_title) - assert !Topic.respond_to?(:find_or_create_by_title?) + assert_not_respond_to Topic, :fail_to_find_by_title + assert_not_respond_to Topic, :find_by_title? + assert_not_respond_to Topic, :fail_to_find_or_create_by_title + assert_not_respond_to Topic, :find_or_create_by_title? end private diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 89d8a8bdca..e73c88dd5d 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/author" @@ -7,6 +9,7 @@ require "models/company" require "models/tagging" require "models/topic" require "models/reply" +require "models/rating" require "models/entrant" require "models/project" require "models/developer" @@ -117,6 +120,21 @@ class FinderTest < ActiveRecord::TestCase assert_equal "The Fourth Topic of the day", records[2].title end + def test_find_with_ids_with_no_id_passed + exception = assert_raises(ActiveRecord::RecordNotFound) { Topic.find } + assert_equal exception.model, "Topic" + assert_equal exception.primary_key, "id" + end + + def test_find_with_ids_with_id_out_of_range + exception = assert_raises(ActiveRecord::RecordNotFound) do + Topic.find("9999999999999999999999999999999") + end + + assert_equal exception.model, "Topic" + assert_equal exception.primary_key, "id" + end + def test_find_passing_active_record_object_is_not_permitted assert_raises(ArgumentError) do Topic.find(Topic.last) @@ -154,6 +172,32 @@ class FinderTest < ActiveRecord::TestCase assert_raise(NoMethodError) { Topic.exists?([1, 2]) } end + def test_exists_with_scope + davids = Author.where(name: "David") + assert_equal true, davids.exists? + assert_equal true, davids.exists?(authors(:david).id) + assert_equal false, davids.exists?(authors(:mary).id) + assert_equal false, davids.exists?("42") + assert_equal false, davids.exists?(42) + assert_equal false, davids.exists?(davids.new.id) + + fake = Author.where(name: "fake author") + assert_equal false, fake.exists? + assert_equal false, fake.exists?(authors(:david).id) + end + + def test_exists_uses_existing_scope + post = authors(:david).posts.first + authors = Author.includes(:posts).where(name: "David", posts: { id: post.id }) + assert_equal true, authors.exists?(authors(:david).id) + end + + def test_any_with_scope_on_hash_includes + post = authors(:david).posts.first + categories = Categorization.includes(author: :posts).where(posts: { id: post.id }) + assert_equal true, categories.exists? + end + def test_exists_with_polymorphic_relation post = Post.create!(title: "Post", body: "default", taggings: [Tagging.new(comment: "tagging comment")]) relation = Post.tagged_with_comment("tagging comment") @@ -202,26 +246,51 @@ class FinderTest < ActiveRecord::TestCase assert_equal true, Topic.first.replies.exists? end - # ensures +exists?+ runs valid SQL by excluding order value - def test_exists_with_order + # Ensure +exists?+ runs without an error by excluding distinct value. + # See https://github.com/rails/rails/pull/26981. + def test_exists_with_order_and_distinct assert_equal true, Topic.order(:id).distinct.exists? end + # Ensure +exists?+ runs without an error by excluding order value. + def test_exists_with_order + assert_equal true, Topic.order(Arel.sql("invalid sql here")).exists? + end + + def test_exists_with_joins + assert_equal true, Topic.joins(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists? + end + + def test_exists_with_left_joins + assert_equal true, Topic.left_joins(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists? + end + + def test_exists_with_eager_load + assert_equal true, Topic.eager_load(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists? + end + def test_exists_with_includes_limit_and_empty_result - assert_equal false, Topic.includes(:replies).limit(0).exists? - assert_equal false, Topic.includes(:replies).limit(1).where("0 = 1").exists? + assert_no_queries { assert_equal false, Topic.includes(:replies).limit(0).exists? } + assert_queries(1) { assert_equal false, Topic.includes(:replies).limit(1).where("0 = 1").exists? } end def test_exists_with_distinct_association_includes_and_limit author = Author.first - assert_equal false, author.unique_categorized_posts.includes(:special_comments).limit(0).exists? - assert_equal true, author.unique_categorized_posts.includes(:special_comments).limit(1).exists? + unique_categorized_posts = author.unique_categorized_posts.includes(:special_comments) + assert_no_queries { assert_equal false, unique_categorized_posts.limit(0).exists? } + assert_queries(1) { assert_equal true, unique_categorized_posts.limit(1).exists? } end def test_exists_with_distinct_association_includes_limit_and_order author = Author.first - assert_equal false, author.unique_categorized_posts.includes(:special_comments).order("comments.tags_count DESC").limit(0).exists? - assert_equal true, author.unique_categorized_posts.includes(:special_comments).order("comments.tags_count DESC").limit(1).exists? + unique_categorized_posts = author.unique_categorized_posts.includes(:special_comments).order("comments.tags_count DESC") + assert_no_queries { assert_equal false, unique_categorized_posts.limit(0).exists? } + assert_queries(1) { assert_equal true, unique_categorized_posts.limit(1).exists? } + end + + def test_exists_should_reference_correct_aliases_while_joining_tables_of_has_many_through_association + ratings = developers(:david).ratings.includes(comment: :post).where(posts: { id: 1 }) + assert_queries(1) { assert_not_predicate ratings.limit(1), :exists? } end def test_exists_with_empty_table_and_no_args_given @@ -236,9 +305,9 @@ class FinderTest < ActiveRecord::TestCase def test_exists_with_aggregate_having_three_mappings_with_one_difference existing_address = customers(:david).address - assert_equal false, Customer.exists?(address: Address.new(existing_address.street, existing_address.city, existing_address.country + "1")) - assert_equal false, Customer.exists?(address: Address.new(existing_address.street, existing_address.city + "1", existing_address.country)) - assert_equal false, Customer.exists?(address: Address.new(existing_address.street + "1", existing_address.city, existing_address.country)) + assert_equal false, Customer.exists?(address: Address.new(existing_address.street, existing_address.city, existing_address.country + "1")) + assert_equal false, Customer.exists?(address: Address.new(existing_address.street, existing_address.city + "1", existing_address.country)) + assert_equal false, Customer.exists?(address: Address.new(existing_address.street + "1", existing_address.city, existing_address.country)) end def test_exists_does_not_instantiate_records @@ -286,6 +355,12 @@ class FinderTest < ActiveRecord::TestCase end def test_find_on_relation_with_large_number + assert_raises(ActiveRecord::RecordNotFound) do + Topic.where("1=1").find(9999999999999999999999999999999) + end + end + + def test_find_by_on_relation_with_large_number assert_nil Topic.where("1=1").find_by(id: 9999999999999999999999999999999) end @@ -345,7 +420,7 @@ class FinderTest < ActiveRecord::TestCase end def test_take - assert_equal topics(:first), Topic.take + assert_equal topics(:first), Topic.where("title = 'The First Topic'").take end def test_take_failing @@ -388,6 +463,7 @@ class FinderTest < ActiveRecord::TestCase expected = topics(:first) expected.touch # PostgreSQL changes the default order if no order clause is used assert_equal expected, Topic.first + assert_equal expected, Topic.limit(5).first end def test_model_class_responds_to_first_bang @@ -410,6 +486,7 @@ class FinderTest < ActiveRecord::TestCase expected = topics(:second) expected.touch # PostgreSQL changes the default order if no order clause is used assert_equal expected, Topic.second + assert_equal expected, Topic.limit(5).second end def test_model_class_responds_to_second_bang @@ -432,6 +509,7 @@ class FinderTest < ActiveRecord::TestCase expected = topics(:third) expected.touch # PostgreSQL changes the default order if no order clause is used assert_equal expected, Topic.third + assert_equal expected, Topic.limit(5).third end def test_model_class_responds_to_third_bang @@ -454,6 +532,7 @@ class FinderTest < ActiveRecord::TestCase expected = topics(:fourth) expected.touch # PostgreSQL changes the default order if no order clause is used assert_equal expected, Topic.fourth + assert_equal expected, Topic.limit(5).fourth end def test_model_class_responds_to_fourth_bang @@ -476,6 +555,7 @@ class FinderTest < ActiveRecord::TestCase expected = topics(:fifth) expected.touch # PostgreSQL changes the default order if no order clause is used assert_equal expected, Topic.fifth + assert_equal expected, Topic.limit(5).fifth end def test_model_class_responds_to_fifth_bang @@ -496,7 +576,7 @@ class FinderTest < ActiveRecord::TestCase assert_nil Topic.offset(4).second_to_last assert_nil Topic.offset(5).second_to_last - #test with limit + # test with limit assert_nil Topic.limit(1).second assert_nil Topic.limit(1).second_to_last end @@ -579,13 +659,13 @@ class FinderTest < ActiveRecord::TestCase def test_last_with_integer_and_order_should_use_sql_limit relation = Topic.order("title") assert_queries(1) { relation.last(5) } - assert !relation.loaded? + assert_not_predicate relation, :loaded? end def test_last_with_integer_and_reorder_should_use_sql_limit relation = Topic.reorder("title") assert_queries(1) { relation.last(5) } - assert !relation.loaded? + assert_not_predicate relation, :loaded? end def test_last_on_loaded_relation_should_not_use_sql @@ -598,7 +678,7 @@ class FinderTest < ActiveRecord::TestCase def test_last_with_irreversible_order assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("coalesce(author_name, title)").last + Topic.order(Arel.sql("coalesce(author_name, title)")).last end end @@ -610,12 +690,42 @@ class FinderTest < ActiveRecord::TestCase assert_equal comments.limit(2).to_a.last(2), comments.limit(2).last(2) assert_equal comments.limit(2).to_a.last(3), comments.limit(2).last(3) + assert_equal comments.offset(2).to_a.last, comments.offset(2).last + assert_equal comments.offset(2).to_a.last(2), comments.offset(2).last(2) + assert_equal comments.offset(2).to_a.last(3), comments.offset(2).last(3) + comments = comments.offset(1) assert_equal comments.limit(2).to_a.last, comments.limit(2).last assert_equal comments.limit(2).to_a.last(2), comments.limit(2).last(2) assert_equal comments.limit(2).to_a.last(3), comments.limit(2).last(3) end + def test_first_on_relation_with_limit_and_offset + post = posts("sti_comments") + + comments = post.comments.order(id: :asc) + assert_equal comments.limit(2).to_a.first, comments.limit(2).first + assert_equal comments.limit(2).to_a.first(2), comments.limit(2).first(2) + assert_equal comments.limit(2).to_a.first(3), comments.limit(2).first(3) + + assert_equal comments.offset(2).to_a.first, comments.offset(2).first + assert_equal comments.offset(2).to_a.first(2), comments.offset(2).first(2) + assert_equal comments.offset(2).to_a.first(3), comments.offset(2).first(3) + + comments = comments.offset(1) + assert_equal comments.limit(2).to_a.first, comments.limit(2).first + assert_equal comments.limit(2).to_a.first(2), comments.limit(2).first(2) + assert_equal comments.limit(2).to_a.first(3), comments.limit(2).first(3) + end + + def test_first_have_determined_order_by_default + expected = [companies(:second_client), companies(:another_client)] + clients = Client.where(name: expected.map(&:name)) + + assert_equal expected, clients.first(2) + assert_equal expected, clients.limit(5).first(2) + end + def test_take_and_first_and_last_with_integer_should_return_an_array assert_kind_of Array, Topic.take(5) assert_kind_of Array, Topic.first(5) @@ -636,8 +746,8 @@ class FinderTest < ActiveRecord::TestCase assert_raise(ActiveModel::MissingAttributeError) { topic.title? } assert_nil topic.read_attribute("title") assert_equal "David", topic.author_name - assert !topic.attribute_present?("title") - assert !topic.attribute_present?(:title) + assert_not topic.attribute_present?("title") + assert_not topic.attribute_present?(:title) assert topic.attribute_present?("author_name") assert_respond_to topic, "author_name" end @@ -721,11 +831,19 @@ class FinderTest < ActiveRecord::TestCase assert_equal [1, 2, 6, 7, 8], Comment.where(id: [1..2, 6..8]).to_a.map(&:id).sort end + def test_find_on_hash_conditions_with_open_ended_range + assert_equal [1, 2, 3], Comment.where(id: Float::INFINITY..3).to_a.map(&:id).sort + end + + def test_find_on_hash_conditions_with_numeric_range_for_string + topic = Topic.create!(title: "12 Factor App") + assert_equal [topic], Topic.where(title: 10..2).to_a + end + def test_find_on_multiple_hash_conditions assert Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: false).find(1) assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: true).find(1) } assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "HHC", replies_count: 1, approved: false).find(1) } - assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: true).find(1) } end def test_condition_interpolation @@ -778,6 +896,25 @@ class FinderTest < ActiveRecord::TestCase assert_equal customers(:david), found_customer end + def test_hash_condition_find_with_aggregate_having_three_mappings_array + david_address = customers(:david).address + zaphod_address = customers(:zaphod).address + barney_address = customers(:barney).address + assert_kind_of Address, david_address + assert_kind_of Address, zaphod_address + found_customers = Customer.where(address: [david_address, zaphod_address, barney_address]) + assert_equal [customers(:david), customers(:zaphod), customers(:barney)], found_customers.sort_by(&:id) + end + + def test_hash_condition_find_with_aggregate_having_one_mapping_array + david_balance = customers(:david).balance + zaphod_balance = customers(:zaphod).balance + assert_kind_of Money, david_balance + assert_kind_of Money, zaphod_balance + found_customers = Customer.where(balance: [david_balance, zaphod_balance]) + assert_equal [customers(:david), customers(:zaphod)], found_customers.sort_by(&:id) + end + def test_hash_condition_find_with_aggregate_attribute_having_same_name_as_field_and_key_value_being_aggregate gps_location = customers(:david).gps_location assert_kind_of GpsLocation, gps_location @@ -983,14 +1120,6 @@ class FinderTest < ActiveRecord::TestCase assert_raise(ArgumentError) { Topic.find_by_title_and_author_name("The First Topic") } end - def test_find_last_with_offset - devs = Developer.order("id") - - assert_equal devs[2], Developer.offset(2).first - assert_equal devs[-3], Developer.offset(2).last - assert_equal devs[-3], Developer.offset(2).order("id DESC").first - end - def test_find_by_nil_attribute topic = Topic.find_by_last_read nil assert_not_nil topic @@ -1006,16 +1135,6 @@ class FinderTest < ActiveRecord::TestCase assert_raise(ActiveRecord::StatementInvalid) { Topic.find_by_sql "select 1 from badtable" } end - def test_find_all_with_join - developers_on_project_one = Developer. - joins("LEFT JOIN developers_projects ON developers.id = developers_projects.developer_id"). - where("project_id=1").to_a - assert_equal 3, developers_on_project_one.length - developer_names = developers_on_project_one.map(&:name) - assert_includes developer_names, "David" - assert_includes developer_names, "Jamis" - end - def test_joins_dont_clobber_id first = Firm. joins("INNER JOIN companies clients ON clients.firm_id = companies.id"). @@ -1089,6 +1208,11 @@ class FinderTest < ActiveRecord::TestCase order("author_addresses_authors.id DESC").limit(3).to_a.size end + def test_find_with_eager_loading_collection_and_ordering_by_collection_primary_key + assert_equal Post.first, Post.eager_load(comments: :ratings). + order("posts.id, ratings.id, comments.id").first + end + def test_find_with_nil_inside_set_passed_for_one_attribute client_of = Company. where(client_of: [2, 1, nil], @@ -1144,7 +1268,7 @@ class FinderTest < ActiveRecord::TestCase e = assert_raises(ActiveRecord::RecordNotFound) do model.find "Hello", "World!" end - assert_equal "Couldn't find all MercedesCars with 'name': (Hello, World!) (found 0 results, but was looking for 2)", e.message + assert_equal "Couldn't find all MercedesCars with 'name': (Hello, World!) (found 0 results, but was looking for 2).", e.message end end @@ -1170,13 +1294,17 @@ class FinderTest < ActiveRecord::TestCase assert_equal posts(:eager_other), Post.find_by("id = ?", posts(:eager_other).id) end + test "find_by with range conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by(id: posts(:eager_other).id...posts(:misc_by_bob).id) + end + test "find_by returns nil if the record is missing" do assert_nil Post.find_by("1 = 0") end test "find_by with associations" do assert_equal authors(:david), Post.find_by(author: authors(:david)).author - assert_equal authors(:mary) , Post.find_by(author: authors(:mary)).author + assert_equal authors(:mary), Post.find_by(author: authors(:mary)).author end test "find_by doesn't have implicit ordering" do @@ -1225,6 +1353,34 @@ class FinderTest < ActiveRecord::TestCase assert_equal tyre2, zyke.tyres.custom_find_by(id: tyre2.id) end + test "#skip_query_cache! for #exists?" do + Topic.cache do + assert_queries(1) do + Topic.exists? + Topic.exists? + end + + assert_queries(2) do + Topic.all.skip_query_cache!.exists? + Topic.all.skip_query_cache!.exists? + end + end + end + + test "#skip_query_cache! for #exists? with a limited eager load" do + Topic.cache do + assert_queries(1) do + Topic.eager_load(:replies).limit(1).exists? + Topic.eager_load(:replies).limit(1).exists? + end + + assert_queries(2) do + Topic.eager_load(:replies).limit(1).skip_query_cache!.exists? + Topic.eager_load(:replies).limit(1).skip_query_cache!.exists? + end + end + end + private def table_with_custom_primary_key yield(Class.new(Toy) do diff --git a/activerecord/test/cases/fixture_set/file_test.rb b/activerecord/test/cases/fixture_set/file_test.rb index 533edcc2e0..ff99988cb5 100644 --- a/activerecord/test/cases/fixture_set/file_test.rb +++ b/activerecord/test/cases/fixture_set/file_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "tempfile" diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb index 51133e9495..c65523d8c1 100644 --- a/activerecord/test/cases/fixtures_test.rb +++ b/activerecord/test/cases/fixtures_test.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require "cases/helper" +require "support/connection_helper" require "models/admin" require "models/admin/account" require "models/admin/randomly_named_c1" @@ -30,6 +33,8 @@ require "models/treasure" require "tempfile" class FixturesTest < ActiveRecord::TestCase + include ConnectionHelper + self.use_instantiated_fixtures = true self.use_transactional_tests = false @@ -54,6 +59,264 @@ class FixturesTest < ActiveRecord::TestCase end end + class InsertQuerySubscriber + attr_reader :events + + def initialize + @events = [] + end + + def call(_, _, _, _, values) + @events << values[:sql] if values[:sql] =~ /INSERT/ + end + end + + if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + def test_bulk_insert + begin + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + create_fixtures("bulbs") + assert_equal 1, subscriber.events.size, "It takes one INSERT query to insert two fixtures" + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + end + + def test_bulk_insert_multiple_table_with_a_multi_statement_query + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + + create_fixtures("bulbs", "authors", "computers") + + expected_sql = <<~EOS.chop + INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("bulbs")} .* + INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("authors")} .* + INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("computers")} .* + EOS + assert_equal 1, subscriber.events.size + assert_match(/#{expected_sql}/, subscriber.events.first) + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + def test_bulk_insert_with_a_multi_statement_query_raises_an_exception_when_any_insert_fails + require "models/aircraft" + + assert_equal false, Aircraft.columns_hash["wheels_count"].null + fixtures = { + "aircraft" => [ + { "name" => "working_aircrafts", "wheels_count" => 2 }, + { "name" => "broken_aircrafts", "wheels_count" => nil }, + ] + } + + assert_no_difference "Aircraft.count" do + assert_raises(ActiveRecord::NotNullViolation) do + ActiveRecord::Base.connection.insert_fixtures_set(fixtures) + end + end + end + + def test_bulk_insert_with_a_multi_statement_query_in_a_nested_transaction + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, + ] + } + + assert_difference "TrafficLight.count" do + ActiveRecord::Base.transaction do + conn = ActiveRecord::Base.connection + assert_equal 1, conn.open_transactions + conn.insert_fixtures_set(fixtures) + assert_equal 1, conn.open_transactions + end + end + end + end + + if current_adapter?(:Mysql2Adapter) + def test_bulk_insert_with_multi_statements_enabled + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection( + orig_connection.merge(flags: %w[MULTI_STATEMENTS]) + ) + + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, + ] + } + + ActiveRecord::Base.connection.stub(:supports_set_server_option?, false) do + assert_nothing_raised do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + + assert_difference "TrafficLight.count" do + ActiveRecord::Base.transaction do + conn = ActiveRecord::Base.connection + assert_equal 1, conn.open_transactions + conn.insert_fixtures_set(fixtures) + assert_equal 1, conn.open_transactions + end + end + + assert_nothing_raised do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + end + end + end + + def test_bulk_insert_with_multi_statements_disabled + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection( + orig_connection.merge(flags: []) + ) + + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, + ] + } + + ActiveRecord::Base.connection.stub(:supports_set_server_option?, false) do + assert_raises(ActiveRecord::StatementInvalid) do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + + assert_difference "TrafficLight.count" do + conn = ActiveRecord::Base.connection + conn.insert_fixtures_set(fixtures) + end + + assert_raises(ActiveRecord::StatementInvalid) do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + end + end + end + + def test_insert_fixtures_set_raises_an_error_when_max_allowed_packet_is_smaller_than_fixtures_set_size + conn = ActiveRecord::Base.connection + mysql_margin = 2 + packet_size = 1024 + bytes_needed_to_have_a_1024_bytes_fixture = 858 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * bytes_needed_to_have_a_1024_bytes_fixture] }, + ] + } + + conn.stub(:max_allowed_packet, packet_size - mysql_margin) do + error = assert_raises(ActiveRecord::ActiveRecordError) { conn.insert_fixtures_set(fixtures) } + assert_match(/Fixtures set is too large #{packet_size}\./, error.message) + end + end + + def test_insert_fixture_set_when_max_allowed_packet_is_bigger_than_fixtures_set_size + conn = ActiveRecord::Base.connection + packet_size = 1024 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 51] }, + ] + } + + conn.stub(:max_allowed_packet, packet_size) do + assert_difference "TrafficLight.count" do + conn.insert_fixtures_set(fixtures) + end + end + end + + def test_insert_fixtures_set_split_the_total_sql_into_two_chunks_smaller_than_max_allowed_packet + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + conn = ActiveRecord::Base.connection + packet_size = 1024 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 450] }, + ], + "comments" => [ + { "post_id" => 1, "body" => "a" * 450 }, + ] + } + + conn.stub(:max_allowed_packet, packet_size) do + conn.insert_fixtures_set(fixtures) + + assert_equal 2, subscriber.events.size + assert_operator subscriber.events.first.bytesize, :<, packet_size + assert_operator subscriber.events.second.bytesize, :<, packet_size + end + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + def test_insert_fixtures_set_concat_total_sql_into_a_single_packet_smaller_than_max_allowed_packet + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + conn = ActiveRecord::Base.connection + packet_size = 1024 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 200] }, + ], + "comments" => [ + { "post_id" => 1, "body" => "a" * 200 }, + ] + } + + conn.stub(:max_allowed_packet, packet_size) do + assert_difference ["TrafficLight.count", "Comment.count"], +1 do + conn.insert_fixtures_set(fixtures) + end + end + assert_equal 1, subscriber.events.size + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + end + + def test_auto_value_on_primary_key + fixtures = [ + { "name" => "first", "wheels_count" => 2 }, + { "name" => "second", "wheels_count" => 3 } + ] + conn = ActiveRecord::Base.connection + assert_nothing_raised do + conn.insert_fixtures_set({ "aircraft" => fixtures }, ["aircraft"]) + end + result = conn.select_all("SELECT name, wheels_count FROM aircraft ORDER BY id") + assert_equal fixtures, result.to_a + end + + def test_deprecated_insert_fixtures + fixtures = [ + { "name" => "first", "wheels_count" => 2 }, + { "name" => "second", "wheels_count" => 3 } + ] + conn = ActiveRecord::Base.connection + conn.delete("DELETE FROM aircraft") + assert_deprecated do + conn.insert_fixtures(fixtures, "aircraft") + end + result = conn.select_all("SELECT name, wheels_count FROM aircraft ORDER BY id") + assert_equal fixtures, result.to_a + end + def test_broken_yaml_exception badyaml = Tempfile.new ["foo", ".yml"] badyaml.write "a: : " @@ -95,6 +358,24 @@ class FixturesTest < ActiveRecord::TestCase assert_nil(topics["second"]["author_email_address"]) end + def test_no_args_returns_all + all_topics = topics + assert_equal 5, all_topics.length + assert_equal "The First Topic", all_topics.first["title"] + assert_equal 5, all_topics.last.id + end + + def test_no_args_record_returns_all_without_array + all_binaries = binaries + assert_kind_of(Array, all_binaries) + assert_equal 2, binaries.length + end + + def test_nil_raises + assert_raise(StandardError) { topics(nil) } + assert_raise(StandardError) { topics([nil]) } + end + def test_inserts create_fixtures("topics") first_row = ActiveRecord::Base.connection.select_one("SELECT * FROM topics WHERE author_name = 'David'") @@ -202,8 +483,8 @@ class FixturesTest < ActiveRecord::TestCase def test_nonexistent_fixture_file nonexistent_fixture_path = FIXTURES_ROOT + "/imnothere" - #sanity check to make sure that this file never exists - assert Dir[nonexistent_fixture_path + "*"].empty? + # sanity check to make sure that this file never exists + assert_empty Dir[nonexistent_fixture_path + "*"] assert_raise(Errno::ENOENT) do ActiveRecord::FixtureSet.new(Account.connection, "companies", Company, nonexistent_fixture_path) @@ -230,7 +511,12 @@ class FixturesTest < ActiveRecord::TestCase e = assert_raise(ActiveRecord::Fixture::FixtureError) do ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "parrots") end - assert_equal(%(table "parrots" has no column named "arrr".), e.message) + + if current_adapter?(:SQLite3Adapter) + assert_equal(%(table "parrots" has no column named "arrr".), e.message) + else + assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message) + end end def test_yaml_file_with_symbol_columns @@ -263,6 +549,7 @@ class FixturesTest < ActiveRecord::TestCase data.force_encoding("ASCII-8BIT") data.freeze assert_equal data, @flowers.data + assert_equal data, @binary_helper.data end def test_serialized_fixtures @@ -347,6 +634,7 @@ if Account.connection.respond_to?(:reset_pk_sequence!) class FixturesResetPkSequenceTest < ActiveRecord::TestCase fixtures :accounts fixtures :companies + self.use_transactional_tests = false def setup @instances = [Account.new(credit_limit: 50), Company.new(name: "RoR Consulting"), Course.new(name: "Test")] @@ -395,14 +683,14 @@ class FixturesWithoutInstantiationTest < ActiveRecord::TestCase fixtures :topics, :developers, :accounts def test_without_complete_instantiation - assert !defined?(@first) - assert !defined?(@topics) - assert !defined?(@developers) - assert !defined?(@accounts) + assert_not defined?(@first) + assert_not defined?(@topics) + assert_not defined?(@developers) + assert_not defined?(@accounts) end def test_fixtures_from_root_yml_without_instantiation - assert !defined?(@unknown), "@unknown is not defined" + assert_not defined?(@unknown), "@unknown is not defined" end def test_visibility_of_accessor_method @@ -437,7 +725,7 @@ class FixturesWithoutInstanceInstantiationTest < ActiveRecord::TestCase fixtures :topics, :developers, :accounts def test_without_instance_instantiation - assert !defined?(@first), "@first is not defined" + assert_not defined?(@first), "@first is not defined" end end @@ -636,44 +924,58 @@ class TransactionalFixturesOnConnectionNotification < ActiveRecord::TestCase self.use_instantiated_fixtures = false def test_transaction_created_on_connection_notification - connection = stub(transaction_open?: false) - connection.expects(:begin_transaction).with(joinable: false) - pool = connection.stubs(:pool).returns(ActiveRecord::ConnectionAdapters::ConnectionPool.new(ActiveRecord::Base.connection_pool.spec)) - pool.stubs(:lock_thread=).with(false) - fire_connection_notification(connection) + connection = Class.new do + attr_accessor :pool + + def transaction_open?; end + def begin_transaction(*args); end + def rollback_transaction(*args); end + end.new + + connection.pool = Class.new do + def lock_thread=(lock_thread); end + end.new + + assert_called_with(connection, :begin_transaction, [joinable: false]) do + fire_connection_notification(connection) + end end def test_notification_established_transactions_are_rolled_back - # Mocha is not thread-safe so define our own stub to test connection = Class.new do attr_accessor :rollback_transaction_called attr_accessor :pool + def transaction_open?; true; end def begin_transaction(*args); end def rollback_transaction(*args) @rollback_transaction_called = true end end.new + connection.pool = Class.new do - def lock_thread=(lock_thread); false; end + def lock_thread=(lock_thread); end end.new + fire_connection_notification(connection) teardown_fixtures + assert(connection.rollback_transaction_called, "Expected <mock connection>#rollback_transaction to be called but was not") end private def fire_connection_notification(connection) - ActiveRecord::Base.connection_handler.stubs(:retrieve_connection).with("book").returns(connection) - message_bus = ActiveSupport::Notifications.instrumenter - payload = { - spec_name: "book", - config: nil, - connection_id: connection.object_id - } + assert_called_with(ActiveRecord::Base.connection_handler, :retrieve_connection, ["book"], returns: connection) do + message_bus = ActiveSupport::Notifications.instrumenter + payload = { + spec_name: "book", + config: nil, + connection_id: connection.object_id + } - message_bus.instrument("!connection.active_record", payload) {} + message_bus.instrument("!connection.active_record", payload) {} + end end end @@ -766,7 +1068,7 @@ end class FasterFixturesTest < ActiveRecord::TestCase self.use_transactional_tests = false - fixtures :categories, :authors + fixtures :categories, :authors, :author_addresses def load_extra_fixture(name) fixture = create_fixtures(name).first @@ -791,6 +1093,8 @@ class FasterFixturesTest < ActiveRecord::TestCase end class FoxyFixturesTest < ActiveRecord::TestCase + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + self.use_transactional_tests = false fixtures :parrots, :parrots_pirates, :pirates, :treasures, :mateys, :ships, :computers, :developers, :"admin/accounts", :"admin/users", :live_parrots, :dead_parrots, :books @@ -883,13 +1187,13 @@ class FoxyFixturesTest < ActiveRecord::TestCase def test_supports_inline_habtm assert(parrots(:george).treasures.include?(treasures(:diamond))) assert(parrots(:george).treasures.include?(treasures(:sapphire))) - assert(!parrots(:george).treasures.include?(treasures(:ruby))) + assert_not(parrots(:george).treasures.include?(treasures(:ruby))) end def test_supports_inline_habtm_with_specified_id assert(parrots(:polly).treasures.include?(treasures(:ruby))) assert(parrots(:polly).treasures.include?(treasures(:sapphire))) - assert(!parrots(:polly).treasures.include?(treasures(:diamond))) + assert_not(parrots(:polly).treasures.include?(treasures(:diamond))) end def test_supports_yaml_arrays @@ -944,10 +1248,10 @@ class FoxyFixturesTest < ActiveRecord::TestCase end def test_resolves_enums - assert books(:awdr).published? - assert books(:awdr).read? - assert books(:rfr).proposed? - assert books(:ddd).published? + assert_predicate books(:awdr), :published? + assert_predicate books(:awdr), :read? + assert_predicate books(:rfr), :proposed? + assert_predicate books(:ddd), :published? end end @@ -987,7 +1291,7 @@ class CustomNameForFixtureOrModelTest < ActiveRecord::TestCase end def test_table_name_is_defined_in_the_model - assert_equal "randomly_named_table2", ActiveRecord::FixtureSet::all_loaded_fixtures["admin/randomly_named_a9"].table_name + assert_equal "randomly_named_table2", ActiveRecord::FixtureSet.all_loaded_fixtures["admin/randomly_named_a9"].table_name assert_equal "randomly_named_table2", Admin::ClassNameThatDoesNotFollowCONVENTIONS1.table_name end end diff --git a/activerecord/test/cases/forbidden_attributes_protection_test.rb b/activerecord/test/cases/forbidden_attributes_protection_test.rb index ffa3f63e0d..101fa118c8 100644 --- a/activerecord/test/cases/forbidden_attributes_protection_test.rb +++ b/activerecord/test/cases/forbidden_attributes_protection_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_support/core_ext/hash/indifferent_access" diff --git a/activerecord/test/cases/habtm_destroy_order_test.rb b/activerecord/test/cases/habtm_destroy_order_test.rb index 365d4576dd..b15e1b48c4 100644 --- a/activerecord/test/cases/habtm_destroy_order_test.rb +++ b/activerecord/test/cases/habtm_destroy_order_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/lesson" require "models/student" @@ -13,7 +15,7 @@ class HabtmDestroyOrderTest < ActiveRecord::TestCase sicp.destroy end end - assert !sicp.destroyed? + assert_not_predicate sicp, :destroyed? end test "should not raise error if have foreign key in the join table" do @@ -40,7 +42,7 @@ class HabtmDestroyOrderTest < ActiveRecord::TestCase ben.lessons << sicp ben.save! ben.destroy - assert !ben.reload.lessons.empty? + assert_not_empty ben.reload.lessons ensure # get rid of it so Student is still like it was Student.reset_callbacks(:destroy) @@ -56,6 +58,6 @@ class HabtmDestroyOrderTest < ActiveRecord::TestCase assert_raises LessonError do sicp.destroy end - assert !sicp.reload.students.empty? + assert_not_empty sicp.reload.students end end diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb index 5a3b8e3fb5..68be685e4b 100644 --- a/activerecord/test/cases/helper.rb +++ b/activerecord/test/cases/helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "config" require "stringio" @@ -141,6 +143,8 @@ def load_schema if File.exist?(adapter_specific_schema_file) load adapter_specific_schema_file end + + ActiveRecord::FixtureSet.reset_cache ensure $stdout = original_stdout end @@ -179,5 +183,3 @@ module InTimeZone ActiveRecord::Base.time_zone_aware_attributes = old_tz end end - -require "mocha/setup" # FIXME: stop using mocha diff --git a/activerecord/test/cases/hot_compatibility_test.rb b/activerecord/test/cases/hot_compatibility_test.rb index e107ff2362..e7778af55b 100644 --- a/activerecord/test/cases/hot_compatibility_test.rb +++ b/activerecord/test/cases/hot_compatibility_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/connection_helper" diff --git a/activerecord/test/cases/i18n_test.rb b/activerecord/test/cases/i18n_test.rb index 7f03c5b23d..22981c142a 100644 --- a/activerecord/test/cases/i18n_test.rb +++ b/activerecord/test/cases/i18n_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" diff --git a/activerecord/test/cases/inheritance_test.rb b/activerecord/test/cases/inheritance_test.rb index e570e9ac1d..3d3189900f 100644 --- a/activerecord/test/cases/inheritance_test.rb +++ b/activerecord/test/cases/inheritance_test.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require "cases/helper" require "models/author" require "models/company" +require "models/membership" require "models/person" require "models/post" require "models/project" @@ -29,7 +32,7 @@ end class InheritanceTest < ActiveRecord::TestCase include InheritanceTestHelper - fixtures :companies, :projects, :subscribers, :accounts, :vegetables + fixtures :companies, :projects, :subscribers, :accounts, :vegetables, :memberships def test_class_with_store_full_sti_class_returns_full_name with_store_full_sti_class do @@ -88,7 +91,6 @@ class InheritanceTest < ActiveRecord::TestCase end ActiveSupport::Dependencies.stub(:safe_constantize, proc { raise e }) do - exception = assert_raises NameError do Company.send :compute_type, "InvalidModel" end @@ -127,56 +129,70 @@ class InheritanceTest < ActiveRecord::TestCase end def test_descends_from_active_record - assert !ActiveRecord::Base.descends_from_active_record? + assert_not_predicate ActiveRecord::Base, :descends_from_active_record? # Abstract subclass of AR::Base. - assert LoosePerson.descends_from_active_record? + assert_predicate LoosePerson, :descends_from_active_record? # Concrete subclass of an abstract class. - assert LooseDescendant.descends_from_active_record? + assert_predicate LooseDescendant, :descends_from_active_record? # Concrete subclass of AR::Base. - assert TightPerson.descends_from_active_record? + assert_predicate TightPerson, :descends_from_active_record? # Concrete subclass of a concrete class but has no type column. - assert TightDescendant.descends_from_active_record? + assert_predicate TightDescendant, :descends_from_active_record? # Concrete subclass of AR::Base. - assert Post.descends_from_active_record? + assert_predicate Post, :descends_from_active_record? + + # Concrete subclasses of a concrete class which has a type column. + assert_not_predicate StiPost, :descends_from_active_record? + assert_not_predicate SubStiPost, :descends_from_active_record? # Abstract subclass of a concrete class which has a type column. # This is pathological, as you'll never have Sub < Abstract < Concrete. - assert !StiPost.descends_from_active_record? + assert_not_predicate AbstractStiPost, :descends_from_active_record? - # Concrete subclasses an abstract class which has a type column. - assert !SubStiPost.descends_from_active_record? + # Concrete subclass of an abstract class which has a type column. + assert_not_predicate SubAbstractStiPost, :descends_from_active_record? end def test_company_descends_from_active_record - assert !ActiveRecord::Base.descends_from_active_record? + assert_not_predicate ActiveRecord::Base, :descends_from_active_record? assert AbstractCompany.descends_from_active_record?, "AbstractCompany should descend from ActiveRecord::Base" assert Company.descends_from_active_record?, "Company should descend from ActiveRecord::Base" - assert !Class.new(Company).descends_from_active_record?, "Company subclass should not descend from ActiveRecord::Base" + assert_not Class.new(Company).descends_from_active_record?, "Company subclass should not descend from ActiveRecord::Base" end def test_abstract_class - assert !ActiveRecord::Base.abstract_class? - assert LoosePerson.abstract_class? - assert !LooseDescendant.abstract_class? + assert_not_predicate ActiveRecord::Base, :abstract_class? + assert_predicate LoosePerson, :abstract_class? + assert_not_predicate LooseDescendant, :abstract_class? end def test_inheritance_base_class assert_equal Post, Post.base_class + assert_predicate Post, :base_class? assert_equal Post, SpecialPost.base_class + assert_not_predicate SpecialPost, :base_class? assert_equal Post, StiPost.base_class - assert_equal SubStiPost, SubStiPost.base_class + assert_not_predicate StiPost, :base_class? + assert_equal Post, SubStiPost.base_class + assert_not_predicate SubStiPost, :base_class? + assert_equal SubAbstractStiPost, SubAbstractStiPost.base_class + assert_predicate SubAbstractStiPost, :base_class? end def test_abstract_inheritance_base_class assert_equal LoosePerson, LoosePerson.base_class + assert_predicate LoosePerson, :base_class? assert_equal LooseDescendant, LooseDescendant.base_class + assert_predicate LooseDescendant, :base_class? assert_equal TightPerson, TightPerson.base_class + assert_predicate TightPerson, :base_class? assert_equal TightPerson, TightDescendant.base_class + assert_not_predicate TightDescendant, :base_class? end def test_base_class_activerecord_error @@ -272,6 +288,21 @@ class InheritanceTest < ActiveRecord::TestCase assert_equal Firm, firm.class end + def test_where_new_with_subclass + firm = Company.where(type: "Firm").new + assert_equal Firm, firm.class + end + + def test_where_create_with_subclass + firm = Company.where(type: "Firm").create(name: "Basecamp") + assert_equal Firm, firm.class + end + + def test_where_create_bang_with_subclass + firm = Company.where(type: "Firm").create!(name: "Basecamp") + assert_equal Firm, firm.class + end + def test_new_with_abstract_class e = assert_raises(NotImplementedError) do AbstractCompany.new @@ -294,6 +325,30 @@ class InheritanceTest < ActiveRecord::TestCase assert_raise(ActiveRecord::SubclassNotFound) { Company.new(type: "Account") } end + def test_where_new_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").new } + end + + def test_where_new_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").new } + end + + def test_where_create_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").create } + end + + def test_where_create_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").create } + end + + def test_where_create_bang_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").create! } + end + + def test_where_create_bang_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").create! } + end + def test_new_with_unrelated_namespaced_type without_store_full_sti_class do e = assert_raises ActiveRecord::SubclassNotFound do @@ -316,7 +371,7 @@ class InheritanceTest < ActiveRecord::TestCase end def test_new_with_autoload_paths - path = File.expand_path("../../models/autoloadable", __FILE__) + path = File.expand_path("../models/autoloadable", __dir__) ActiveSupport::Dependencies.autoload_paths << path firm = Company.new(type: "ExtraFirm") @@ -417,7 +472,8 @@ class InheritanceTest < ActiveRecord::TestCase def test_eager_load_belongs_to_primary_key_quoting con = Account.connection - assert_sql(/#{con.quote_table_name('companies')}.#{con.quote_column_name('id')} = 1/) do + bind_param = Arel::Nodes::BindParam.new(nil) + assert_sql(/#{con.quote_table_name('companies')}\.#{con.quote_column_name('id')} = (?:#{Regexp.quote(bind_param.to_sql)}|1)/) do Account.all.merge!(includes: :firm).find(1) end end @@ -435,6 +491,10 @@ class InheritanceTest < ActiveRecord::TestCase assert_nothing_raised { Company.of_first_firm } assert_nothing_raised { Client.of_first_firm } end + + def test_inheritance_with_default_scope + assert_equal 1, SelectedMembership.count(:all) + end end class InheritanceComputeTypeTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb new file mode 100644 index 0000000000..e6e8468757 --- /dev/null +++ b/activerecord/test/cases/instrumentation_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" + +module ActiveRecord + class InstrumentationTest < ActiveRecord::TestCase + def test_payload_name_on_load + Book.create(name: "test book") + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "SELECT" + assert_equal "Book Load", event.payload[:name] + end + end + Book.first + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_create + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "INSERT" + assert_equal "Book Create", event.payload[:name] + end + end + Book.create(name: "test book") + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_update + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "UPDATE" + assert_equal "Book Update", event.payload[:name] + end + end + book = Book.create(name: "test book") + book.update_attribute(:name, "new name") + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_update_all + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "UPDATE" + assert_equal "Book Update All", event.payload[:name] + end + end + Book.create(name: "test book") + Book.update_all(name: "new name") + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_destroy + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "DELETE" + assert_equal "Book Destroy", event.payload[:name] + end + end + book = Book.create(name: "test book") + book.destroy + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end +end diff --git a/activerecord/test/cases/integration_test.rb b/activerecord/test/cases/integration_test.rb index d7aa091623..36cd63c4d4 100644 --- a/activerecord/test/cases/integration_test.rb +++ b/activerecord/test/cases/integration_test.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require "cases/helper" require "models/company" @@ -169,13 +170,65 @@ class IntegrationTest < ActiveRecord::TestCase end def test_named_timestamps_for_cache_key - owner = owners(:blackbeard) - assert_equal "owners/#{owner.id}-#{owner.happy_at.utc.to_s(:usec)}", owner.cache_key(:updated_at, :happy_at) + assert_deprecated do + owner = owners(:blackbeard) + assert_equal "owners/#{owner.id}-#{owner.happy_at.utc.to_s(:usec)}", owner.cache_key(:updated_at, :happy_at) + end end def test_cache_key_when_named_timestamp_is_nil - owner = owners(:blackbeard) - owner.happy_at = nil - assert_equal "owners/#{owner.id}", owner.cache_key(:happy_at) + assert_deprecated do + owner = owners(:blackbeard) + owner.happy_at = nil + assert_equal "owners/#{owner.id}", owner.cache_key(:happy_at) + end + end + + def test_cache_key_is_stable_with_versioning_on + Developer.cache_versioning = true + + developer = Developer.first + first_key = developer.cache_key + + developer.touch + second_key = developer.cache_key + + assert_equal first_key, second_key + ensure + Developer.cache_versioning = false + end + + def test_cache_version_changes_with_versioning_on + Developer.cache_versioning = true + + developer = Developer.first + first_version = developer.cache_version + + travel 10.seconds do + developer.touch + end + + second_version = developer.cache_version + + assert_not_equal first_version, second_version + ensure + Developer.cache_versioning = false + end + + def test_cache_key_retains_version_when_custom_timestamp_is_used + Developer.cache_versioning = true + + developer = Developer.first + first_key = developer.cache_key_with_version + + travel 10.seconds do + developer.touch + end + + second_key = developer.cache_key_with_version + + assert_not_equal first_key, second_key + ensure + Developer.cache_versioning = false end end diff --git a/activerecord/test/cases/invalid_connection_test.rb b/activerecord/test/cases/invalid_connection_test.rb index 1367af2859..a1be9c2780 100644 --- a/activerecord/test/cases/invalid_connection_test.rb +++ b/activerecord/test/cases/invalid_connection_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" if current_adapter?(:Mysql2Adapter) diff --git a/activerecord/test/cases/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb index cc3951e2ba..363beb4780 100644 --- a/activerecord/test/cases/invertible_migration_test.rb +++ b/activerecord/test/cases/invertible_migration_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Horse < ActiveRecord::Base @@ -159,6 +161,15 @@ module ActiveRecord end end + class UpOnlyMigration < SilentMigration + def change + add_column :horses, :oldie, :integer, default: 0 + up_only { execute "update horses set oldie = 1" } + end + end + + self.use_transactional_tests = false + setup do @verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false end @@ -204,7 +215,7 @@ module ActiveRecord migration = InvertibleMigration.new migration.migrate :up migration.migrate :down - assert !migration.connection.table_exists?("horses") + assert_not migration.connection.table_exists?("horses") end def test_migrate_revert @@ -212,11 +223,11 @@ module ActiveRecord revert = InvertibleRevertMigration.new migration.migrate :up revert.migrate :up - assert !migration.connection.table_exists?("horses") + assert_not migration.connection.table_exists?("horses") revert.migrate :down assert migration.connection.table_exists?("horses") migration.migrate :down - assert !migration.connection.table_exists?("horses") + assert_not migration.connection.table_exists?("horses") end def test_migrate_revert_by_part @@ -230,12 +241,12 @@ module ActiveRecord } migration.migrate :up assert_equal [:both, :up], received - assert !migration.connection.table_exists?("horses") + assert_not migration.connection.table_exists?("horses") assert migration.connection.table_exists?("new_horses") migration.migrate :down assert_equal [:both, :up, :both, :down], received assert migration.connection.table_exists?("horses") - assert !migration.connection.table_exists?("new_horses") + assert_not migration.connection.table_exists?("new_horses") end def test_migrate_revert_whole_migration @@ -244,11 +255,11 @@ module ActiveRecord revert = RevertWholeMigration.new(klass) migration.migrate :up revert.migrate :up - assert !migration.connection.table_exists?("horses") + assert_not migration.connection.table_exists?("horses") revert.migrate :down assert migration.connection.table_exists?("horses") migration.migrate :down - assert !migration.connection.table_exists?("horses") + assert_not migration.connection.table_exists?("horses") end end @@ -257,7 +268,7 @@ module ActiveRecord revert.migrate :down assert revert.connection.table_exists?("horses") revert.migrate :up - assert !revert.connection.table_exists?("horses") + assert_not revert.connection.table_exists?("horses") end def test_migrate_revert_change_column_default @@ -293,6 +304,8 @@ module ActiveRecord migration2.migrate(:down) assert_equal false, Horse.connection.extension_enabled?("hstore") + ensure + enable_extension!("hstore", ActiveRecord::Base.connection) end end @@ -328,7 +341,7 @@ module ActiveRecord def test_legacy_down LegacyMigration.migrate :up LegacyMigration.migrate :down - assert !ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist" + assert_not ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist" end def test_up @@ -339,7 +352,7 @@ module ActiveRecord def test_down LegacyMigration.up LegacyMigration.down - assert !ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist" + assert_not ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist" end def test_migrate_down_with_table_name_prefix @@ -348,7 +361,7 @@ module ActiveRecord migration = InvertibleMigration.new migration.migrate(:up) assert_nothing_raised { migration.migrate(:down) } - assert !ActiveRecord::Base.connection.table_exists?("p_horses_s"), "p_horses_s should not exist" + assert_not ActiveRecord::Base.connection.table_exists?("p_horses_s"), "p_horses_s should not exist" ensure ActiveRecord::Base.table_name_prefix = ActiveRecord::Base.table_name_suffix = "" end @@ -370,9 +383,27 @@ module ActiveRecord connection = ActiveRecord::Base.connection assert connection.index_exists?(:horses, :content), "index on content should exist" - assert !connection.index_exists?(:horses, :content, name: "horses_index_named"), + assert_not connection.index_exists?(:horses, :content, name: "horses_index_named"), "horses_index_named index should not exist" end end + + def test_up_only + InvertibleMigration.new.migrate(:up) + horse1 = Horse.create + # populates existing horses with oldie = 1 but new ones have default 0 + UpOnlyMigration.new.migrate(:up) + Horse.reset_column_information + horse1.reload + horse2 = Horse.create + + assert 1, horse1.oldie # created before migration + assert 0, horse2.oldie # created after migration + + UpOnlyMigration.new.migrate(:down) # should be no error + connection = ActiveRecord::Base.connection + assert_not connection.column_exists?(:horses, :oldie) + Horse.reset_column_information + end end end diff --git a/activerecord/test/cases/json_attribute_test.rb b/activerecord/test/cases/json_attribute_test.rb new file mode 100644 index 0000000000..afc39d0420 --- /dev/null +++ b/activerecord/test/cases/json_attribute_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/json_shared_test_cases" + +class JsonAttributeTest < ActiveRecord::TestCase + include JSONSharedTestCases + self.use_transactional_tests = false + + class JsonDataTypeOnText < ActiveRecord::Base + self.table_name = "json_data_type" + + attribute :payload, :json + attribute :settings, :json + + store_accessor :settings, :resolution + end + + def setup + super + @connection.create_table("json_data_type") do |t| + t.string "payload" + t.string "settings" + end + end + + private + def column_type + :string + end + + def klass + JsonDataTypeOnText + end +end diff --git a/activerecord/test/cases/json_serialization_test.rb b/activerecord/test/cases/json_serialization_test.rb index 5a1d066aef..82cf281cff 100644 --- a/activerecord/test/cases/json_serialization_test.rb +++ b/activerecord/test/cases/json_serialization_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/contact" require "models/post" @@ -160,15 +162,13 @@ class JsonSerializationTest < ActiveRecord::TestCase end def test_serializable_hash_should_not_modify_options_in_argument - options = { only: :name } - @contact.serializable_hash(options) - - assert_nil options[:except] + options = { only: :name }.freeze + assert_nothing_raised { @contact.serializable_hash(options) } end end class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase - fixtures :authors, :posts, :comments, :tags, :taggings + fixtures :authors, :author_addresses, :posts, :comments, :tags, :taggings include JsonSerializationHelpers @@ -252,7 +252,7 @@ class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase def @david.favorite_quote; "Constraints are liberating"; end json = @david.to_json(include: :posts, methods: :favorite_quote) - assert !@david.posts.first.respond_to?(:favorite_quote) + assert_not_respond_to @david.posts.first, :favorite_quote assert_match %r{"favorite_quote":"Constraints are liberating"}, json assert_equal 1, %r{"favorite_quote":}.match(json).size end diff --git a/activerecord/test/cases/json_shared_test_cases.rb b/activerecord/test/cases/json_shared_test_cases.rb new file mode 100644 index 0000000000..9b79803503 --- /dev/null +++ b/activerecord/test/cases/json_shared_test_cases.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "support/schema_dumping_helper" + +module JSONSharedTestCases + include SchemaDumpingHelper + + class JsonDataType < ActiveRecord::Base + self.table_name = "json_data_type" + + store_accessor :settings, :resolution + end + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table :json_data_type, if_exists: true + klass.reset_column_information + end + + def test_column + column = klass.columns_hash["payload"] + assert_equal column_type, column.type + assert_type_match column_type, column.sql_type + + type = klass.type_for_attribute("payload") + assert_not_predicate type, :binary? + end + + def test_change_table_supports_json + @connection.change_table("json_data_type") do |t| + t.public_send column_type, "users" + end + klass.reset_column_information + column = klass.columns_hash["users"] + assert_equal column_type, column.type + assert_type_match column_type, column.sql_type + end + + def test_schema_dumping + output = dump_table_schema("json_data_type") + assert_match(/t\.#{column_type}\s+"settings"/, output) + end + + def test_cast_value_on_write + x = klass.new(payload: { "string" => "foo", :symbol => :bar }) + assert_equal({ "string" => "foo", :symbol => :bar }, x.payload_before_type_cast) + assert_equal({ "string" => "foo", "symbol" => "bar" }, x.payload) + x.save! + assert_equal({ "string" => "foo", "symbol" => "bar" }, x.reload.payload) + end + + def test_type_cast_json + type = klass.type_for_attribute("payload") + + data = '{"a_key":"a_value"}' + hash = type.deserialize(data) + assert_equal({ "a_key" => "a_value" }, hash) + assert_equal({ "a_key" => "a_value" }, type.deserialize(data)) + + assert_equal({}, type.deserialize("{}")) + assert_equal({ "key" => nil }, type.deserialize('{"key": null}')) + assert_equal({ "c" => "}", '"a"' => 'b "a b' }, type.deserialize(%q({"c":"}", "\"a\"":"b \"a b"}))) + end + + def test_rewrite + @connection.execute(insert_statement_per_database('{"k":"v"}')) + x = klass.first + x.payload = { '"a\'' => "b" } + assert x.save! + end + + def test_select + @connection.execute(insert_statement_per_database('{"k":"v"}')) + x = klass.first + assert_equal({ "k" => "v" }, x.payload) + end + + def test_select_multikey + @connection.execute(insert_statement_per_database('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')) + x = klass.first + assert_equal({ "k1" => "v1", "k2" => "v2", "k3" => [1, 2, 3] }, x.payload) + end + + def test_null_json + @connection.execute(insert_statement_per_database("null")) + x = klass.first + assert_nil(x.payload) + end + + def test_select_nil_json_after_create + json = klass.create!(payload: nil) + x = klass.where(payload: nil).first + assert_equal(json, x) + end + + def test_select_nil_json_after_update + json = klass.create!(payload: "foo") + x = klass.where(payload: nil).first + assert_nil(x) + + json.update(payload: nil) + x = klass.where(payload: nil).first + assert_equal(json.reload, x) + end + + def test_select_array_json_value + @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]')) + x = klass.first + assert_equal(["v0", { "k1" => "v1" }], x.payload) + end + + def test_rewrite_array_json_value + @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]')) + x = klass.first + x.payload = ["v1", { "k2" => "v2" }, "v3"] + assert x.save! + end + + def test_with_store_accessors + x = klass.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + x.save! + x = klass.first + assert_equal "320×480", x.resolution + + x.resolution = "640×1136" + x.save! + + x = klass.first + assert_equal "640×1136", x.resolution + end + + def test_duplication_with_store_accessors + x = klass.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + y = x.dup + assert_equal "320×480", y.resolution + end + + def test_yaml_round_trip_with_store_accessors + x = klass.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + y = YAML.load(YAML.dump(x)) + assert_equal "320×480", y.resolution + end + + def test_changes_in_place + json = klass.new + assert_not_predicate json, :changed? + + json.payload = { "one" => "two" } + assert_predicate json, :changed? + assert_predicate json, :payload_changed? + + json.save! + assert_not_predicate json, :changed? + + json.payload["three"] = "four" + assert_predicate json, :payload_changed? + + json.save! + json.reload + + assert_equal({ "one" => "two", "three" => "four" }, json.payload) + assert_not_predicate json, :changed? + end + + def test_changes_in_place_ignores_key_order + json = klass.new + assert_not_predicate json, :changed? + + json.payload = { "three" => "four", "one" => "two" } + json.save! + json.reload + + json.payload = { "three" => "four", "one" => "two" } + assert_not_predicate json, :changed? + + json.payload = [{ "three" => "four", "one" => "two" }, { "seven" => "eight", "five" => "six" }] + json.save! + json.reload + + json.payload = [{ "three" => "four", "one" => "two" }, { "seven" => "eight", "five" => "six" }] + assert_not_predicate json, :changed? + end + + def test_changes_in_place_with_ruby_object + time = Time.now.utc + json = klass.create!(payload: time) + + json.reload + assert_not_predicate json, :changed? + + json.payload = time + assert_not_predicate json, :changed? + end + + def test_assigning_string_literal + json = klass.create!(payload: "foo") + assert_equal "foo", json.payload + end + + def test_assigning_number + json = klass.create!(payload: 1.234) + assert_equal 1.234, json.payload + end + + def test_assigning_boolean + json = klass.create!(payload: true) + assert_equal true, json.payload + end + + def test_not_compatible_with_serialize_json + new_klass = Class.new(klass) do + serialize :payload, JSON + end + assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do + new_klass.new + end + end + + class MySettings + def initialize(hash); @hash = hash end + def to_hash; @hash end + def self.load(hash); new(hash) end + def self.dump(object); object.to_hash end + end + + def test_json_with_serialized_attributes + new_klass = Class.new(klass) do + serialize :settings, MySettings + end + + new_klass.create!(settings: MySettings.new("one" => "two")) + record = new_klass.first + + assert_instance_of MySettings, record.settings + assert_equal({ "one" => "two" }, record.settings.to_hash) + + record.settings = MySettings.new("three" => "four") + record.save! + + assert_equal({ "three" => "four" }, record.reload.settings.to_hash) + end + + private + def klass + JsonDataType + end + + def assert_type_match(type, sql_type) + native_type = ActiveRecord::Base.connection.native_database_types[type][:name] + assert_match %r(\A#{native_type}\b), sql_type + end + + def insert_statement_per_database(values) + if current_adapter?(:OracleAdapter) + "insert into json_data_type (id, payload) VALUES (json_data_type_seq.nextval, '#{values}')" + else + "insert into json_data_type (payload) VALUES ('#{values}')" + end + end +end diff --git a/activerecord/test/cases/legacy_configurations_test.rb b/activerecord/test/cases/legacy_configurations_test.rb new file mode 100644 index 0000000000..c36feb5116 --- /dev/null +++ b/activerecord/test/cases/legacy_configurations_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class LegacyConfigurationsTest < ActiveRecord::TestCase + def test_can_turn_configurations_into_a_hash + assert ActiveRecord::Base.configurations.to_h.is_a?(Hash), "expected to be a hash but was not." + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"].sort, ActiveRecord::Base.configurations.to_h.keys.sort + end + + def test_each_is_deprecated + assert_deprecated do + ActiveRecord::Base.configurations.each do |db_config| + assert_equal "primary", db_config.spec_name + end + end + end + + def test_first_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_fetch_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.fetch("arunit").first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_values_are_deprecated + config_hashes = ActiveRecord::Base.configurations.configurations.map(&:config) + assert_deprecated do + assert_equal config_hashes, ActiveRecord::Base.configurations.values + end + end + end +end diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb index 23095618a4..33bd74e114 100644 --- a/activerecord/test/cases/locking_test.rb +++ b/activerecord/test/cases/locking_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "thread" require "cases/helper" require "models/person" @@ -13,6 +15,7 @@ require "models/bulb" require "models/engine" require "models/wheel" require "models/treasure" +require "models/frog" class LockWithoutDefault < ActiveRecord::Base; end @@ -67,8 +70,8 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_raise(ActiveRecord::StaleObjectError) { s2.destroy } assert s1.destroy - assert s1.frozen? - assert s1.destroyed? + assert_predicate s1, :frozen? + assert_predicate s1, :destroyed? assert_raises(ActiveRecord::RecordNotFound) { StringKeyObject.find("record1") } end @@ -102,8 +105,8 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_raises(ActiveRecord::StaleObjectError) { p2.destroy } assert p1.destroy - assert p1.frozen? - assert p1.destroyed? + assert_predicate p1, :frozen? + assert_predicate p1, :destroyed? assert_raises(ActiveRecord::RecordNotFound) { Person.find(1) } end @@ -167,6 +170,12 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal 0, p1.lock_version end + def test_lock_new_when_explicitly_passing_value + p1 = Person.new(first_name: "Douglas Adams", lock_version: 42) + p1.save! + assert_equal 42, p1.lock_version + end + def test_touch_existing_lock p1 = Person.find(1) assert_equal 0, p1.lock_version @@ -186,6 +195,58 @@ class OptimisticLockingTest < ActiveRecord::TestCase end end + def test_update_with_dirty_primary_key + assert_raises(ActiveRecord::RecordNotUnique) do + person = Person.find(1) + person.id = 2 + person.save! + end + + person = Person.find(1) + person.id = 42 + person.save! + + assert Person.find(42) + assert_raises(ActiveRecord::RecordNotFound) do + Person.find(1) + end + end + + def test_delete_with_dirty_primary_key + person = Person.find(1) + person.id = 2 + person.delete + + assert Person.find(2) + assert_raises(ActiveRecord::RecordNotFound) do + Person.find(1) + end + end + + def test_destroy_with_dirty_primary_key + person = Person.find(1) + person.id = 2 + person.destroy + + assert Person.find(2) + assert_raises(ActiveRecord::RecordNotFound) do + Person.find(1) + end + end + + def test_explicit_update_lock_column_raise_error + person = Person.find(1) + + assert_raises(ActiveRecord::StaleObjectError) do + person.first_name = "Douglas Adams" + person.lock_version = 42 + + assert_predicate person, :lock_version_changed? + + person.save + end + end + def test_lock_column_name_existing t1 = LegacyThing.find(1) t2 = LegacyThing.find(1) @@ -225,10 +286,33 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal 0, t1.lock_version_before_type_cast end + def test_touch_existing_lock_without_default_should_work_with_null_in_the_database + ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')") + t1 = LockWithoutDefault.last + + assert_equal 0, t1.lock_version + assert_nil t1.lock_version_before_type_cast + + t1.touch + + assert_equal 1, t1.lock_version + end + + def test_touch_stale_object_with_lock_without_default + t1 = LockWithoutDefault.create!(title: "title1") + stale_object = LockWithoutDefault.find(t1.id) + + t1.update!(title: "title2") + + assert_raises(ActiveRecord::StaleObjectError) do + stale_object.touch + end + end + def test_lock_without_default_should_work_with_null_in_the_database ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')") t1 = LockWithoutDefault.last - t2 = LockWithoutDefault.last + t2 = LockWithoutDefault.find(t1.id) assert_equal 0, t1.lock_version assert_nil t1.lock_version_before_type_cast @@ -247,17 +331,6 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal "new title2", t2.title end - def test_lock_without_default_should_update_with_lock_col - t1 = LockWithoutDefault.create(title: "title1", lock_version: 6) - - assert_equal 6, t1.lock_version - - t1.update(lock_version: 0) - t1.reload - - assert_equal 0, t1.lock_version - end - def test_lock_without_default_queries_count t1 = LockWithoutDefault.create(title: "title1") @@ -270,12 +343,6 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal "title2", t1.title assert_equal 1, t1.lock_version - assert_queries(1) { t1.update(title: "title3", lock_version: 6) } - - t1.reload - assert_equal "title3", t1.title - assert_equal 6, t1.lock_version - t2 = LockWithoutDefault.new(title: "title1") assert_queries(1) { t2.save! } @@ -302,7 +369,7 @@ class OptimisticLockingTest < ActiveRecord::TestCase ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults_cust(title) VALUES('title1')") t1 = LockWithCustomColumnWithoutDefault.last - t2 = LockWithCustomColumnWithoutDefault.last + t2 = LockWithCustomColumnWithoutDefault.find(t1.id) assert_equal 0, t1.custom_lock_version assert_nil t1.custom_lock_version_before_type_cast @@ -321,17 +388,6 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal "new title2", t2.title end - def test_lock_with_custom_column_without_default_should_update_with_lock_col - t1 = LockWithCustomColumnWithoutDefault.create(title: "title1", custom_lock_version: 6) - - assert_equal 6, t1.custom_lock_version - - t1.update(custom_lock_version: 0) - t1.reload - - assert_equal 0, t1.custom_lock_version - end - def test_lock_with_custom_column_without_default_queries_count t1 = LockWithCustomColumnWithoutDefault.create(title: "title1") @@ -344,12 +400,6 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal "title2", t1.title assert_equal 1, t1.custom_lock_version - assert_queries(1) { t1.update(title: "title3", custom_lock_version: 6) } - - t1.reload - assert_equal "title3", t1.title - assert_equal 6, t1.custom_lock_version - t2 = LockWithCustomColumnWithoutDefault.new(title: "title1") assert_queries(1) { t2.save! } @@ -389,25 +439,65 @@ class OptimisticLockingTest < ActiveRecord::TestCase end end + def test_counter_cache_with_touch_and_lock_version + car = Car.create! + + assert_equal 0, car.wheels_count + assert_equal 0, car.lock_version + + previously_updated_at = car.updated_at + previously_wheels_owned_at = car.wheels_owned_at + travel(1.second) do + Wheel.create!(wheelable: car) + end + + assert_equal 1, car.reload.wheels_count + assert_equal 1, car.lock_version + assert_operator previously_updated_at, :<, car.updated_at + assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at + + previously_updated_at = car.updated_at + previously_wheels_owned_at = car.wheels_owned_at + travel(2.second) do + car.wheels.first.update(size: 42) + end + + assert_equal 1, car.reload.wheels_count + assert_equal 2, car.lock_version + assert_operator previously_updated_at, :<, car.updated_at + assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at + + previously_updated_at = car.updated_at + previously_wheels_owned_at = car.wheels_owned_at + travel(3.second) do + car.wheels.first.destroy! + end + + assert_equal 0, car.reload.wheels_count + assert_equal 3, car.lock_version + assert_operator previously_updated_at, :<, car.updated_at + assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at + end + def test_polymorphic_destroy_with_dependencies_and_lock_version car = Car.create! assert_difference "car.wheels.count" do - car.wheels << Wheel.create! + car.wheels.create end assert_difference "car.wheels.count", -1 do car.reload.destroy end - assert car.destroyed? + assert_predicate car, :destroyed? end def test_removing_has_and_belongs_to_many_associations_upon_destroy p = RichPerson.create! first_name: "Jon" p.treasures.create! - assert !p.treasures.empty? + assert_not_empty p.treasures p.destroy - assert p.treasures.empty? - assert RichPerson.connection.select_all("SELECT * FROM peoples_treasures WHERE rich_person_id = 1").empty? + assert_empty p.treasures + assert_empty RichPerson.connection.select_all("SELECT * FROM peoples_treasures WHERE rich_person_id = 1") end def test_yaml_dumping_with_lock_column @@ -468,6 +558,31 @@ class OptimisticLockingWithSchemaChangeTest < ActiveRecord::TestCase PersonalLegacyThing.reset_column_information end + def test_destroy_existing_object_with_locking_column_value_null_in_the_database + ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')") + t1 = LockWithoutDefault.last + + assert_equal 0, t1.lock_version + assert_nil t1.lock_version_before_type_cast + + t1.destroy + + assert_predicate t1, :destroyed? + end + + def test_destroy_stale_object + t1 = LockWithoutDefault.create!(title: "title1") + stale_object = LockWithoutDefault.find(t1.id) + + t1.update!(title: "title2") + + assert_raises(ActiveRecord::StaleObjectError) do + stale_object.destroy! + end + + assert_not_predicate stale_object, :destroyed? + end + private def add_counter_column_to(model, col = "test_count") @@ -530,17 +645,27 @@ unless in_memory_db? end end - # Locking a record reloads it. - def test_sane_lock_method + def test_lock_does_not_raise_when_the_object_is_not_dirty + person = Person.find 1 assert_nothing_raised do - Person.transaction do - person = Person.find 1 - old, person.first_name = person.first_name, "fooman" - # Locking a dirty record is deprecated - assert_deprecated do - person.lock! - end - assert_equal old, person.first_name + person.lock! + end + end + + def test_lock_raises_when_the_record_is_dirty + person = Person.find 1 + person.first_name = "fooman" + assert_raises(RuntimeError) do + person.lock! + end + end + + def test_locking_in_after_save_callback + assert_nothing_raised do + frog = ::Frog.create(name: "Old Frog") + frog.name = "New Frog" + assert_not_deprecated do + frog.save! end end end @@ -576,14 +701,12 @@ unless in_memory_db? end end - if current_adapter?(:PostgreSQLAdapter, :OracleAdapter) - def test_no_locks_no_wait - first, second = duel { Person.find 1 } - assert first.end > second.end - end - - private + def test_no_locks_no_wait + first, second = duel { Person.find 1 } + assert first.end > second.end + end + private def duel(zzz = 5) t0, t1, t2, t3 = nil, nil, nil, nil @@ -611,6 +734,5 @@ unless in_memory_db? assert t3 > t2 [t0.to_f..t1.to_f, t2.to_f..t3.to_f] end - end end end diff --git a/activerecord/test/cases/log_subscriber_test.rb b/activerecord/test/cases/log_subscriber_test.rb index 90ad970e16..ae2597adc8 100644 --- a/activerecord/test/cases/log_subscriber_test.rb +++ b/activerecord/test/cases/log_subscriber_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/binary" require "models/developer" @@ -21,6 +23,7 @@ class LogSubscriberTest < ActiveRecord::TestCase TRANSACTION: REGEXP_CYAN, OTHER: REGEXP_MAGENTA } + Event = Struct.new(:duration, :payload) class TestDebugLogSubscriber < ActiveRecord::LogSubscriber attr_reader :debugs @@ -30,8 +33,9 @@ class LogSubscriberTest < ActiveRecord::TestCase super end - def debug(message) - @debugs << message + def debug(progname = nil, &block) + @debugs << progname + super end end @@ -40,6 +44,7 @@ class LogSubscriberTest < ActiveRecord::TestCase def setup @old_logger = ActiveRecord::Base.logger Developer.primary_key + ActiveRecord::Base.connection.materialize_transactions super ActiveRecord::LogSubscriber.attach_to(:active_record) end @@ -55,25 +60,22 @@ class LogSubscriberTest < ActiveRecord::TestCase end def test_schema_statements_are_ignored - event = Struct.new(:duration, :payload) - logger = TestDebugLogSubscriber.new assert_equal 0, logger.debugs.length - logger.sql(event.new(0, sql: "hi mom!")) + logger.sql(Event.new(0.9, sql: "hi mom!")) assert_equal 1, logger.debugs.length - logger.sql(event.new(0, sql: "hi mom!", name: "foo")) + logger.sql(Event.new(0.9, sql: "hi mom!", name: "foo")) assert_equal 2, logger.debugs.length - logger.sql(event.new(0, sql: "hi mom!", name: "SCHEMA")) + logger.sql(Event.new(0.9, sql: "hi mom!", name: "SCHEMA")) assert_equal 2, logger.debugs.length end def test_sql_statements_are_not_squeezed - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new - logger.sql(event.new(0, sql: "ruby rails")) + logger.sql(Event.new(0.9, sql: "ruby rails")) assert_match(/ruby rails/, logger.debugs.first) end @@ -86,56 +88,51 @@ class LogSubscriberTest < ActiveRecord::TestCase end def test_basic_query_logging_coloration - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new logger.colorize_logging = true SQL_COLORINGS.each do |verb, color_regex| - logger.sql(event.new(0, sql: verb.to_s)) + logger.sql(Event.new(0.9, sql: verb.to_s)) assert_match(/#{REGEXP_BOLD}#{color_regex}#{verb}#{REGEXP_CLEAR}/i, logger.debugs.last) end end def test_basic_payload_name_logging_coloration_generic_sql - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new logger.colorize_logging = true SQL_COLORINGS.each do |verb, _| - logger.sql(event.new(0, sql: verb.to_s)) - assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0.0ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + logger.sql(Event.new(0.9, sql: verb.to_s)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) - logger.sql(event.new(0, sql: verb.to_s, name: "SQL")) - assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA}SQL \(0.0ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + logger.sql(Event.new(0.9, sql: verb.to_s, name: "SQL")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA}SQL \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) end end def test_basic_payload_name_logging_coloration_named_sql - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new logger.colorize_logging = true SQL_COLORINGS.each do |verb, _| - logger.sql(event.new(0, sql: verb.to_s, name: "Model Load")) - assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Load \(0.0ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + logger.sql(Event.new(0.9, sql: verb.to_s, name: "Model Load")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Load \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) - logger.sql(event.new(0, sql: verb.to_s, name: "Model Exists")) - assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Exists \(0.0ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + logger.sql(Event.new(0.9, sql: verb.to_s, name: "Model Exists")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Exists \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) - logger.sql(event.new(0, sql: verb.to_s, name: "ANY SPECIFIC NAME")) - assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}ANY SPECIFIC NAME \(0.0ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + logger.sql(Event.new(0.9, sql: verb.to_s, name: "ANY SPECIFIC NAME")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}ANY SPECIFIC NAME \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) end end def test_query_logging_coloration_with_nested_select - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new logger.colorize_logging = true SQL_COLORINGS.slice(:SELECT, :INSERT, :UPDATE, :DELETE).each do |verb, color_regex| - logger.sql(event.new(0, sql: "#{verb} WHERE ID IN SELECT")) - assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0.0ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}#{verb} WHERE ID IN SELECT#{REGEXP_CLEAR}/i, logger.debugs.last) + logger.sql(Event.new(0.9, sql: "#{verb} WHERE ID IN SELECT")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}#{verb} WHERE ID IN SELECT#{REGEXP_CLEAR}/i, logger.debugs.last) end end def test_query_logging_coloration_with_multi_line_nested_select - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new logger.colorize_logging = true SQL_COLORINGS.slice(:SELECT, :INSERT, :UPDATE, :DELETE).each do |verb, color_regex| @@ -145,13 +142,12 @@ class LogSubscriberTest < ActiveRecord::TestCase SELECT ID FROM THINGS ) EOS - logger.sql(event.new(0, sql: sql)) - assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0.0ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}.*#{verb}.*#{REGEXP_CLEAR}/mi, logger.debugs.last) + logger.sql(Event.new(0.9, sql: sql)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}.*#{verb}.*#{REGEXP_CLEAR}/mi, logger.debugs.last) end end def test_query_logging_coloration_with_lock - event = Struct.new(:duration, :payload) logger = TestDebugLogSubscriber.new logger.colorize_logging = true sql = <<-EOS @@ -159,14 +155,14 @@ class LogSubscriberTest < ActiveRecord::TestCase (SELECT * FROM mytable FOR UPDATE) ss WHERE col1 = 5; EOS - logger.sql(event.new(0, sql: sql)) - assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0.0ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*FOR UPDATE.*#{REGEXP_CLEAR}/mi, logger.debugs.last) + logger.sql(Event.new(0.9, sql: sql)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*FOR UPDATE.*#{REGEXP_CLEAR}/mi, logger.debugs.last) sql = <<-EOS LOCK TABLE films IN SHARE MODE; EOS - logger.sql(event.new(0, sql: sql)) - assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0.0ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*LOCK TABLE.*#{REGEXP_CLEAR}/mi, logger.debugs.last) + logger.sql(Event.new(0.9, sql: sql)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*LOCK TABLE.*#{REGEXP_CLEAR}/mi, logger.debugs.last) end def test_exists_query_logging @@ -177,6 +173,36 @@ class LogSubscriberTest < ActiveRecord::TestCase assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last) end + def test_vebose_query_logs + ActiveRecord::Base.verbose_query_logs = true + + logger = TestDebugLogSubscriber.new + logger.sql(Event.new(0, sql: "hi mom!")) + assert_equal 2, @logger.logged(:debug).size + assert_match(/↳/, @logger.logged(:debug).last) + ensure + ActiveRecord::Base.verbose_query_logs = false + end + + def test_verbose_query_with_ignored_callstack + ActiveRecord::Base.verbose_query_logs = true + + logger = TestDebugLogSubscriber.new + def logger.extract_query_source_location(*); nil; end + + logger.sql(Event.new(0, sql: "hi mom!")) + assert_equal 1, @logger.logged(:debug).size + assert_no_match(/↳/, @logger.logged(:debug).last) + ensure + ActiveRecord::Base.verbose_query_logs = false + end + + def test_verbose_query_logs_disabled_by_default + logger = TestDebugLogSubscriber.new + logger.sql(Event.new(0, sql: "hi mom!")) + assert_no_match(/↳/, @logger.logged(:debug).last) + end + def test_cached_queries ActiveRecord::Base.cache do Developer.all.load diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb index 1d305fa11f..7777508349 100644 --- a/activerecord/test/cases/migration/change_schema_test.rb +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -82,7 +84,7 @@ module ActiveRecord columns = connection.columns(:testings) array_column = columns.detect { |c| c.name == "foo" } - assert array_column.array? + assert_predicate array_column, :array? end def test_create_table_with_array_column @@ -93,7 +95,7 @@ module ActiveRecord columns = connection.columns(:testings) array_column = columns.detect { |c| c.name == "foo" } - assert array_column.array? + assert_predicate array_column, :array? end end @@ -194,6 +196,17 @@ module ActiveRecord assert_equal "you can't redefine the primary key column 'testing_id'. To define a custom primary key, pass { id: false } to create_table.", error.message end + def test_create_table_raises_when_defining_existing_column + error = assert_raise(ArgumentError) do + connection.create_table :testings do |t| + t.column :testing_column, :string + t.column :testing_column, :integer + end + end + + assert_equal "you can't define an already defined column 'testing_column'.", error.message + end + def test_create_table_with_timestamps_should_create_datetime_columns connection.create_table table_name do |t| t.timestamps @@ -203,8 +216,8 @@ module ActiveRecord created_at_column = created_columns.detect { |c| c.name == "created_at" } updated_at_column = created_columns.detect { |c| c.name == "updated_at" } - assert !created_at_column.null - assert !updated_at_column.null + assert_not created_at_column.null + assert_not updated_at_column.null end def test_create_table_with_timestamps_should_create_datetime_columns_with_options @@ -262,17 +275,18 @@ module ActiveRecord t.column :foo, :timestamp end - klass = Class.new(ActiveRecord::Base) - klass.table_name = "testings" + column = connection.columns(:testings).find { |c| c.name == "foo" } - assert_equal :datetime, klass.columns_hash["foo"].type + assert_equal :datetime, column.type if current_adapter?(:PostgreSQLAdapter) - assert_equal "timestamp without time zone", klass.columns_hash["foo"].sql_type + assert_equal "timestamp without time zone", column.sql_type elsif current_adapter?(:Mysql2Adapter) - assert_equal "timestamp", klass.columns_hash["foo"].sql_type + assert_equal "timestamp", column.sql_type + elsif current_adapter?(:OracleAdapter) + assert_equal "TIMESTAMP(6)", column.sql_type else - assert_equal klass.connection.type_to_sql("datetime"), klass.columns_hash["foo"].sql_type + assert_equal connection.type_to_sql("datetime"), column.sql_type end end @@ -405,7 +419,7 @@ module ActiveRecord end connection.change_table :testings do |t| assert t.column_exists?(:foo) - assert !(t.column_exists?(:bar)) + assert_not (t.column_exists?(:bar)) end end diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb index ec817a579b..034bf32165 100644 --- a/activerecord/test/cases/migration/change_table_test.rb +++ b/activerecord/test/cases/migration/change_table_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/migration/helper" module ActiveRecord diff --git a/activerecord/test/cases/migration/column_attributes_test.rb b/activerecord/test/cases/migration/column_attributes_test.rb index 48df931543..3022121f4c 100644 --- a/activerecord/test/cases/migration/column_attributes_test.rb +++ b/activerecord/test/cases/migration/column_attributes_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/migration/helper" module ActiveRecord @@ -43,11 +45,11 @@ module ActiveRecord assert_nil TestModel.columns_hash["description"].limit end - if current_adapter?(:Mysql2Adapter) + if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) def test_unabstracted_database_dependent_types - add_column :test_models, :intelligence_quotient, :tinyint + add_column :test_models, :intelligence_quotient, :smallint TestModel.reset_column_information - assert_match(/tinyint/, TestModel.columns_hash["intelligence_quotient"].sql_type) + assert_match(/smallint/, TestModel.columns_hash["intelligence_quotient"].sql_type) end end @@ -78,7 +80,7 @@ module ActiveRecord TestModel.delete_all # Now use the Rails insertion - TestModel.create wealth: BigDecimal.new("12345678901234567890.0123456789") + TestModel.create wealth: BigDecimal("12345678901234567890.0123456789") # SELECT row = TestModel.first @@ -97,7 +99,21 @@ module ActiveRecord assert_equal 7, wealth_column.scale end + # Test SQLite3 adapter specifically for decimal types with precision and scale + # attributes, since these need to be maintained in schema but aren't actually + # used in SQLite3 itself if current_adapter?(:SQLite3Adapter) + def test_change_column_with_new_precision_and_scale + connection.add_column "test_models", "wealth", :decimal, precision: 9, scale: 7 + + connection.change_column "test_models", "wealth", :decimal, precision: 12, scale: 8 + TestModel.reset_column_information + + wealth_column = TestModel.columns_hash["wealth"] + assert_equal 12, wealth_column.precision + assert_equal 8, wealth_column.scale + end + def test_change_column_preserve_other_column_precision_and_scale connection.add_column "test_models", "last_name", :string connection.add_column "test_models", "wealth", :decimal, precision: 9, scale: 7 @@ -130,7 +146,7 @@ module ActiveRecord TestModel.create first_name: "bob", last_name: "bobsen", bio: "I was born ....", age: 18, height: 1.78, - wealth: BigDecimal.new("12345678901234567890.0123456789"), + wealth: BigDecimal("12345678901234567890.0123456789"), birthday: 18.years.ago, favorite_day: 10.days.ago, moment_of_truth: "1782-10-10 21:40:18", male: true @@ -143,7 +159,7 @@ module ActiveRecord # Test for 30 significant digits (beyond the 16 of float), 10 of them # after the decimal place. - assert_equal BigDecimal.new("0012345678901234567890.0123456789"), bob.wealth + assert_equal BigDecimal("0012345678901234567890.0123456789"), bob.wealth assert_equal true, bob.male? diff --git a/activerecord/test/cases/migration/column_positioning_test.rb b/activerecord/test/cases/migration/column_positioning_test.rb index f2162d91b1..1c62a68cf9 100644 --- a/activerecord/test/cases/migration/column_positioning_test.rb +++ b/activerecord/test/cases/migration/column_positioning_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -50,6 +52,16 @@ module ActiveRecord conn.change_column :testings, :second, :integer, after: :third assert_equal %w(first third second), conn.columns(:testings).map(&:name) end + + def test_add_reference_with_positioning_first + conn.add_reference :testings, :new, polymorphic: true, first: true + assert_equal %w(new_id new_type first second third), conn.columns(:testings).map(&:name) + end + + def test_add_reference_with_positioning_after + conn.add_reference :testings, :new, polymorphic: true, after: :first + assert_equal %w(first new_id new_type second third), conn.columns(:testings).map(&:name) + end end end end diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb index 2329888345..cedd9c44e3 100644 --- a/activerecord/test/cases/migration/columns_test.rb +++ b/activerecord/test/cases/migration/columns_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/migration/helper" module ActiveRecord @@ -65,7 +67,7 @@ module ActiveRecord if current_adapter?(:Mysql2Adapter) def test_mysql_rename_column_preserves_auto_increment rename_column "test_models", "id", "id_test" - assert connection.columns("test_models").find { |c| c.name == "id_test" }.auto_increment? + assert_predicate connection.columns("test_models").find { |c| c.name == "id_test" }, :auto_increment? TestModel.reset_column_information ensure rename_column "test_models", "id_test", "id" @@ -140,6 +142,10 @@ module ActiveRecord end 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" + add_column "test_models", :hat_size, :integer add_column "test_models", :hat_style, :string, limit: 100 add_index "test_models", ["hat_style", "hat_size"], unique: true @@ -217,31 +223,31 @@ module ActiveRecord def test_change_column_with_nil_default add_column "test_models", "contributor", :boolean, default: true - assert TestModel.new.contributor? + assert_predicate TestModel.new, :contributor? change_column "test_models", "contributor", :boolean, default: nil TestModel.reset_column_information - assert_not TestModel.new.contributor? + assert_not_predicate TestModel.new, :contributor? assert_nil TestModel.new.contributor end def test_change_column_to_drop_default_with_null_false add_column "test_models", "contributor", :boolean, default: true, null: false - assert TestModel.new.contributor? + assert_predicate TestModel.new, :contributor? change_column "test_models", "contributor", :boolean, default: nil, null: false TestModel.reset_column_information - assert_not TestModel.new.contributor? + assert_not_predicate TestModel.new, :contributor? assert_nil TestModel.new.contributor end def test_change_column_with_new_default add_column "test_models", "administrator", :boolean, default: true - assert TestModel.new.administrator? + assert_predicate TestModel.new, :administrator? change_column "test_models", "administrator", :boolean, default: false TestModel.reset_column_information - assert_not TestModel.new.administrator? + assert_not_predicate TestModel.new, :administrator? end def test_change_column_with_custom_index_name diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb index 802a969cb7..1a19b8dafd 100644 --- a/activerecord/test/cases/migration/command_recorder_test.rb +++ b/activerecord/test/cases/migration/command_recorder_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -12,7 +14,7 @@ module ActiveRecord recorder = CommandRecorder.new(Class.new { def america; end }.new) - assert recorder.respond_to?(:america) + assert_respond_to recorder, :america end def test_send_calls_super @@ -25,7 +27,7 @@ module ActiveRecord recorder = CommandRecorder.new(Class.new { def create_table(name); end }.new) - assert recorder.respond_to?(:create_table), "respond_to? create_table" + assert_respond_to recorder, :create_table recorder.send(:create_table, :horses) assert_equal [[:create_table, [:horses], nil]], recorder.commands end @@ -211,9 +213,9 @@ module ActiveRecord assert_equal [:remove_index, [:table, { name: "new_index" }]], remove end - def test_invert_add_index_with_no_options - remove = @recorder.inverse_of :add_index, [:table, [:one, :two]] - assert_equal [:remove_index, [:table, { column: [:one, :two] }]], remove + def test_invert_add_index_with_algorithm_option + remove = @recorder.inverse_of :add_index, [:table, :one, algorithm: :concurrently] + assert_equal [:remove_index, [:table, { column: :one, algorithm: :concurrently }]], remove end def test_invert_remove_index @@ -327,11 +329,24 @@ module ActiveRecord assert_equal [:add_foreign_key, [:dogs, :people, primary_key: "person_id"]], enable end + def test_invert_remove_foreign_key_with_primary_key_and_to_table_in_options + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people, primary_key: "uuid"] + assert_equal [:add_foreign_key, [:dogs, :people, primary_key: "uuid"]], enable + end + def test_invert_remove_foreign_key_with_on_delete_on_update enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, on_delete: :nullify, on_update: :cascade] assert_equal [:add_foreign_key, [:dogs, :people, on_delete: :nullify, on_update: :cascade]], enable end + def test_invert_remove_foreign_key_with_to_table_in_options + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people] + assert_equal [:add_foreign_key, [:dogs, :people]], enable + + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people, column: :owner_id] + assert_equal [:add_foreign_key, [:dogs, :people, column: :owner_id]], enable + end + def test_invert_remove_foreign_key_is_irreversible_without_to_table assert_raises ActiveRecord::IrreversibleMigration do @recorder.inverse_of :remove_foreign_key, [:dogs, column: "owner_id"] diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb index 7a80bfb899..69a50674af 100644 --- a/activerecord/test/cases/migration/compatibility_test.rb +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -14,7 +16,7 @@ module ActiveRecord ActiveRecord::Migration.verbose = false connection.create_table :testings do |t| - t.column :foo, :string, limit: 100 + t.column :foo, :string, limit: 5 t.column :bar, :string, limit: 100 end end @@ -90,6 +92,21 @@ module ActiveRecord connection.drop_table :more_testings rescue nil end + def test_timestamps_have_null_constraints_if_not_present_in_migration_of_change_table + migration = Class.new(ActiveRecord::Migration[4.2]) { + def migrate(x) + change_table :testings do |t| + t.timestamps + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.columns(:testings).find { |c| c.name == "created_at" }.null + assert connection.columns(:testings).find { |c| c.name == "updated_at" }.null + end + def test_timestamps_have_null_constraints_if_not_present_in_migration_for_adding_timestamps_to_existing_table migration = Class.new(ActiveRecord::Migration[4.2]) { def migrate(x) @@ -109,15 +126,32 @@ module ActiveRecord end assert_match(/LegacyMigration < ActiveRecord::Migration\[4\.2\]/, e.message) end + + if current_adapter?(:PostgreSQLAdapter) + class Testing < ActiveRecord::Base + end + + def test_legacy_change_column_with_null_executes_update + migration = Class.new(ActiveRecord::Migration[5.1]) { + def migrate(x) + change_column :testings, :foo, :string, limit: 10, null: false, default: "foobar" + end + }.new + + Testing.create! + ActiveRecord::Migrator.new(:up, [migration]).migrate + assert_equal ["foobar"], Testing.all.map(&:foo) + ensure + ActiveRecord::Base.clear_cache! + end + end end end end -class LegacyPrimaryKeyTest < ActiveRecord::TestCase +module LegacyPrimaryKeyTestCases include SchemaDumpingHelper - self.use_transactional_tests = false - class LegacyPrimaryKey < ActiveRecord::Base end @@ -135,7 +169,7 @@ class LegacyPrimaryKeyTest < ActiveRecord::TestCase end def test_legacy_primary_key_should_be_auto_incremented - @migration = Class.new(ActiveRecord::Migration[5.0]) { + @migration = Class.new(migration_class) { def change create_table :legacy_primary_keys do |t| t.references :legacy_ref @@ -145,12 +179,10 @@ class LegacyPrimaryKeyTest < ActiveRecord::TestCase @migration.migrate(:up) - legacy_pk = LegacyPrimaryKey.columns_hash["id"] - assert_not legacy_pk.bigint? - assert_not legacy_pk.null + assert_legacy_primary_key legacy_ref = LegacyPrimaryKey.columns_hash["legacy_ref_id"] - assert_not legacy_ref.bigint? + assert_not_predicate legacy_ref, :bigint? record1 = LegacyPrimaryKey.create! assert_not_nil record1.id @@ -165,7 +197,7 @@ class LegacyPrimaryKeyTest < ActiveRecord::TestCase def test_legacy_integer_primary_key_should_not_be_auto_incremented skip if current_adapter?(:SQLite3Adapter) - @migration = Class.new(ActiveRecord::Migration[5.0]) { + @migration = Class.new(migration_class) { def change create_table :legacy_primary_keys, id: :integer do |t| end @@ -182,9 +214,85 @@ class LegacyPrimaryKeyTest < ActiveRecord::TestCase assert_match %r{create_table "legacy_primary_keys", id: :integer, default: nil}, schema end + def test_legacy_primary_key_in_create_table_should_be_integer + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: false do |t| + t.primary_key :id + end + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + end + + def test_legacy_primary_key_in_change_table_should_be_integer + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: false do |t| + t.integer :dummy + end + change_table :legacy_primary_keys do |t| + t.primary_key :id + end + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + end + + def test_add_column_with_legacy_primary_key_should_be_integer + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: false do |t| + t.integer :dummy + end + add_column :legacy_primary_keys, :id, :primary_key + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + end + + def test_legacy_join_table_foreign_keys_should_be_integer + @migration = Class.new(migration_class) { + def change + create_join_table :apples, :bananas do |t| + end + end + }.new + + @migration.migrate(:up) + + schema = dump_table_schema "apples_bananas" + assert_match %r{integer "apple_id", null: false}, schema + assert_match %r{integer "banana_id", null: false}, schema + end + + def test_legacy_join_table_column_options_should_be_overwritten + @migration = Class.new(migration_class) { + def change + create_join_table :apples, :bananas, column_options: { type: :bigint } do |t| + end + end + }.new + + @migration.migrate(:up) + + schema = dump_table_schema "apples_bananas" + assert_match %r{bigint "apple_id", null: false}, schema + assert_match %r{bigint "banana_id", null: false}, schema + end + if current_adapter?(:Mysql2Adapter) def test_legacy_bigint_primary_key_should_be_auto_incremented - @migration = Class.new(ActiveRecord::Migration[5.0]) { + @migration = Class.new(migration_class) { def change create_table :legacy_primary_keys, id: :bigint end @@ -193,15 +301,15 @@ class LegacyPrimaryKeyTest < ActiveRecord::TestCase @migration.migrate(:up) legacy_pk = LegacyPrimaryKey.columns_hash["id"] - assert legacy_pk.bigint? - assert legacy_pk.auto_increment? + assert_predicate legacy_pk, :bigint? + assert_predicate legacy_pk, :auto_increment? schema = dump_table_schema "legacy_primary_keys" assert_match %r{create_table "legacy_primary_keys", (?!id: :bigint, default: nil)}, schema end else def test_legacy_bigint_primary_key_should_not_be_auto_incremented - @migration = Class.new(ActiveRecord::Migration[5.0]) { + @migration = Class.new(migration_class) { def change create_table :legacy_primary_keys, id: :bigint do |t| end @@ -218,4 +326,44 @@ class LegacyPrimaryKeyTest < ActiveRecord::TestCase assert_match %r{create_table "legacy_primary_keys", id: :bigint, default: nil}, schema end end + + private + def assert_legacy_primary_key + assert_equal "id", LegacyPrimaryKey.primary_key + + legacy_pk = LegacyPrimaryKey.columns_hash["id"] + + assert_equal :integer, legacy_pk.type + assert_not_predicate legacy_pk, :bigint? + assert_not legacy_pk.null + + if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + schema = dump_table_schema "legacy_primary_keys" + assert_match %r{create_table "legacy_primary_keys", id: :(?:integer|serial), (?!default: nil)}, schema + end + end +end + +module LegacyPrimaryKeyTest + class V5_0 < ActiveRecord::TestCase + include LegacyPrimaryKeyTestCases + + self.use_transactional_tests = false + + private + def migration_class + ActiveRecord::Migration[5.0] + end + end + + class V4_2 < ActiveRecord::TestCase + include LegacyPrimaryKeyTestCases + + self.use_transactional_tests = false + + private + def migration_class + ActiveRecord::Migration[4.2] + end + end end diff --git a/activerecord/test/cases/migration/create_join_table_test.rb b/activerecord/test/cases/migration/create_join_table_test.rb index 26b1bb4419..e0cbb29dcf 100644 --- a/activerecord/test/cases/migration/create_join_table_test.rb +++ b/activerecord/test/cases/migration/create_join_table_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -12,7 +14,7 @@ module ActiveRecord teardown do %w(artists_musics musics_videos catalog).each do |table_name| - connection.drop_table table_name if connection.table_exists?(table_name) + connection.drop_table table_name, if_exists: true end end @@ -67,7 +69,7 @@ module ActiveRecord def test_create_join_table_without_indexes connection.create_join_table :artists, :musics - assert connection.indexes(:artists_musics).blank? + assert_predicate connection.indexes(:artists_musics), :blank? end def test_create_join_table_with_index @@ -78,46 +80,57 @@ module ActiveRecord assert_equal [%w(artist_id music_id)], connection.indexes(:artists_musics).map(&:columns) end + def test_create_join_table_respects_reference_key_type + connection.create_join_table :artists, :musics do |t| + t.references :video + end + + artist_id, music_id, video_id = connection.columns(:artists_musics).sort_by(&:name) + + assert_equal video_id.sql_type, artist_id.sql_type + assert_equal video_id.sql_type, music_id.sql_type + end + def test_drop_join_table connection.create_join_table :artists, :musics connection.drop_join_table :artists, :musics - assert !connection.table_exists?("artists_musics") + assert_not connection.table_exists?("artists_musics") end def test_drop_join_table_with_strings connection.create_join_table :artists, :musics connection.drop_join_table "artists", "musics" - assert !connection.table_exists?("artists_musics") + assert_not connection.table_exists?("artists_musics") end def test_drop_join_table_with_the_proper_order connection.create_join_table :videos, :musics connection.drop_join_table :videos, :musics - assert !connection.table_exists?("musics_videos") + assert_not connection.table_exists?("musics_videos") end def test_drop_join_table_with_the_table_name connection.create_join_table :artists, :musics, table_name: :catalog connection.drop_join_table :artists, :musics, table_name: :catalog - assert !connection.table_exists?("catalog") + assert_not connection.table_exists?("catalog") end def test_drop_join_table_with_the_table_name_as_string connection.create_join_table :artists, :musics, table_name: "catalog" connection.drop_join_table :artists, :musics, table_name: "catalog" - assert !connection.table_exists?("catalog") + assert_not connection.table_exists?("catalog") end def test_drop_join_table_with_column_options connection.create_join_table :artists, :musics, column_options: { null: true } connection.drop_join_table :artists, :musics, column_options: { null: true } - assert !connection.table_exists?("artists_musics") + assert_not connection.table_exists?("artists_musics") end def test_create_and_drop_join_table_with_common_prefix @@ -126,7 +139,7 @@ module ActiveRecord assert connection.table_exists?("audio_artists_musics") connection.drop_join_table "audio_artists", "audio_musics" - assert !connection.table_exists?("audio_artists_musics"), "Should have dropped join table, but didn't" + assert_not connection.table_exists?("audio_artists_musics"), "Should have dropped join table, but didn't" end end diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index 7762d37915..bb233fbf74 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -17,6 +19,85 @@ if ActiveRecord::Base.connection.supports_foreign_keys_in_create? assert_equal "fk_name", fk.name unless current_adapter?(:SQLite3Adapter) end end + + class ForeignKeyChangeColumnTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class Rocket < ActiveRecord::Base + has_many :astronauts + end + + class Astronaut < ActiveRecord::Base + belongs_to :rocket + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "rockets", force: true do |t| + t.string :name + end + + @connection.create_table "astronauts", force: true do |t| + t.string :name + t.references :rocket, foreign_key: true + end + Rocket.reset_column_information + Astronaut.reset_column_information + end + + teardown do + @connection.drop_table "astronauts", if_exists: true + @connection.drop_table "rockets", if_exists: true + Rocket.reset_column_information + Astronaut.reset_column_information + end + + def test_change_column_of_parent_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.change_column_null :rockets, :name, false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + end + + def test_rename_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.rename_column :astronauts, :name, :astronaut_name + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + end + + def test_rename_reference_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.rename_column :astronauts, :rocket_id, :new_rocket_id + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + assert_equal "new_rocket_id", fk.options[:column] + end + end end end end @@ -225,6 +306,74 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end + if ActiveRecord::Base.connection.supports_validate_constraints? + def test_add_invalid_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_not_predicate fk, :validated? + end + + def test_validate_foreign_key_infers_column + @connection.add_foreign_key :astronauts, :rockets, validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, :rockets + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_key_by_column + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, column: "rocket_id" + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_key_by_symbol_column + @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id, validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, column: :rocket_id + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_key_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, name: "fancy_named_fk" + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_non_existing_foreign_key_raises + assert_raises ArgumentError do + @connection.validate_foreign_key :astronauts, :rockets + end + end + + def test_validate_constraint_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false + + @connection.validate_constraint :astronauts, "fancy_named_fk" + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + else + # Foreign key should still be created, but should not be invalid + def test_add_invalid_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_predicate fk, :validated? + end + end + def test_schema_dumping @connection.add_foreign_key :astronauts, :rockets output = dump_table_schema "astronauts" @@ -236,6 +385,17 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output end + def test_schema_dumping_with_custom_fk_ignore_pattern + original_pattern = ActiveRecord::SchemaDumper.fk_ignore_pattern + ActiveRecord::SchemaDumper.fk_ignore_pattern = /^ignored_/ + @connection.add_foreign_key :astronauts, :rockets, name: :ignored_fk_astronauts_rockets + + output = dump_table_schema "astronauts" + assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output + + ActiveRecord::SchemaDumper.fk_ignore_pattern = original_pattern + end + def test_schema_dumping_on_delete_and_on_update_options @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :nullify, on_update: :cascade diff --git a/activerecord/test/cases/migration/helper.rb b/activerecord/test/cases/migration/helper.rb index 9c0fa7339d..c056199140 100644 --- a/activerecord/test/cases/migration/helper.rb +++ b/activerecord/test/cases/migration/helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/migration/index_test.rb b/activerecord/test/cases/migration/index_test.rb index f10fcf1398..f8fecc83cd 100644 --- a/activerecord/test/cases/migration/index_test.rb +++ b/activerecord/test/cases/migration/index_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -31,10 +33,8 @@ module ActiveRecord connection.add_index(table_name, [:foo], name: "old_idx") connection.rename_index(table_name, "old_idx", "new_idx") - assert_deprecated do - assert_not connection.index_name_exists?(table_name, "old_idx", false) - assert connection.index_name_exists?(table_name, "new_idx", true) - end + assert_not connection.index_name_exists?(table_name, "old_idx") + assert connection.index_name_exists?(table_name, "new_idx") end def test_rename_index_too_long @@ -99,7 +99,7 @@ module ActiveRecord connection.add_index :testings, :foo assert connection.index_exists?(:testings, :foo) - assert !connection.index_exists?(:testings, :bar) + assert_not connection.index_exists?(:testings, :bar) end def test_index_exists_on_multiple_columns @@ -131,15 +131,18 @@ module ActiveRecord assert connection.index_exists?(:testings, :foo) assert connection.index_exists?(:testings, :foo, name: "custom_index_name") - assert !connection.index_exists?(:testings, :foo, name: "other_index_name") + assert_not connection.index_exists?(:testings, :foo, name: "other_index_name") end def test_remove_named_index - connection.add_index :testings, :foo, name: "custom_index_name" + connection.add_index :testings, :foo, name: "index_testings_on_custom_index_name" assert connection.index_exists?(:testings, :foo) + + assert_raise(ArgumentError) { connection.remove_index(:testings, "custom_index_name") } + connection.remove_index :testings, :foo - assert !connection.index_exists?(:testings, :foo) + assert_not connection.index_exists?(:testings, :foo) end def test_add_index_attribute_length_limit @@ -203,7 +206,7 @@ module ActiveRecord assert connection.index_exists?("testings", "last_name") connection.remove_index("testings", "last_name") - assert !connection.index_exists?("testings", "last_name") + assert_not connection.index_exists?("testings", "last_name") end end diff --git a/activerecord/test/cases/migration/logger_test.rb b/activerecord/test/cases/migration/logger_test.rb index 3d7c7ad469..28f4cc124b 100644 --- a/activerecord/test/cases/migration/logger_test.rb +++ b/activerecord/test/cases/migration/logger_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/migration/pending_migrations_test.rb b/activerecord/test/cases/migration/pending_migrations_test.rb index 6970fdcc87..dedb5ea502 100644 --- a/activerecord/test/cases/migration/pending_migrations_test.rb +++ b/activerecord/test/cases/migration/pending_migrations_test.rb @@ -1,38 +1,40 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord class Migration - class PendingMigrationsTest < ActiveRecord::TestCase - def setup - super - @connection = Minitest::Mock.new - @app = Minitest::Mock.new - conn = @connection - @pending = Class.new(CheckPending) { - define_method(:connection) { conn } - }.new(@app) - @pending.instance_variable_set :@last_check, -1 # Force checking - end + if current_adapter?(:SQLite3Adapter) && !in_memory_db? + class PendingMigrationsTest < ActiveRecord::TestCase + setup do + file = ActiveRecord::Base.connection.raw_connection.filename + @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:", migrations_paths: MIGRATIONS_ROOT + "/valid" + source_db = SQLite3::Database.new file + dest_db = ActiveRecord::Base.connection.raw_connection + backup = SQLite3::Backup.new(dest_db, "main", source_db, "main") + backup.step(-1) + backup.finish + end - def teardown - assert @connection.verify - assert @app.verify - super - end + teardown do + @conn.release_connection if @conn + ActiveRecord::Base.establish_connection :arunit + end + + def test_errors_if_pending + ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true - def test_errors_if_pending - ActiveRecord::Migrator.stub :needs_migration?, true do - assert_raise ActiveRecord::PendingMigrationError do - @pending.call(nil) + assert_raises ActiveRecord::PendingMigrationError do + CheckPending.new(Proc.new {}).call({}) end end - end - def test_checks_if_supported - @app.expect :call, nil, [:foo] + def test_checks_if_supported + ActiveRecord::SchemaMigration.create_table + migrator = Base.connection.migration_context + capture(:stdout) { migrator.migrate } - ActiveRecord::Migrator.stub :needs_migration?, false do - @pending.call(:foo) + assert_nil CheckPending.new(Proc.new {}).call({}) end end end diff --git a/activerecord/test/cases/migration/references_foreign_key_test.rb b/activerecord/test/cases/migration/references_foreign_key_test.rb index f1ddac1ee2..7a092103c7 100644 --- a/activerecord/test/cases/migration/references_foreign_key_test.rb +++ b/activerecord/test/cases/migration/references_foreign_key_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" if ActiveRecord::Base.connection.supports_foreign_keys_in_create? @@ -139,6 +141,16 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end + test "removing column removes foreign key" do + @connection.create_table :testings do |t| + t.references :testing_parent, index: true, foreign_key: true + end + + assert_difference "@connection.foreign_keys('testings').size", -1 do + @connection.remove_column :testings, :testing_parent_id + end + end + test "foreign key methods respect pluralize_table_names" do begin original_pluralize_table_names = ActiveRecord::Base.pluralize_table_names diff --git a/activerecord/test/cases/migration/references_index_test.rb b/activerecord/test/cases/migration/references_index_test.rb index 2866cabab6..e41377d817 100644 --- a/activerecord/test/cases/migration/references_index_test.rb +++ b/activerecord/test/cases/migration/references_index_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/migration/references_statements_test.rb b/activerecord/test/cases/migration/references_statements_test.rb index df15d7cb45..769241ba12 100644 --- a/activerecord/test/cases/migration/references_statements_test.rb +++ b/activerecord/test/cases/migration/references_statements_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/migration/helper" module ActiveRecord @@ -50,6 +52,21 @@ module ActiveRecord assert column_exists?(table_name, :taggable_type, :string, default: "Photo") end + def test_creates_reference_type_column_with_not_null + connection.create_table table_name, force: true do |t| + t.references :taggable, null: false, polymorphic: true + end + assert column_exists?(table_name, :taggable_id, :integer, null: false) + assert column_exists?(table_name, :taggable_type, :string, null: false) + end + + def test_does_not_share_options_with_reference_type_column + add_reference table_name, :taggable, type: :integer, limit: 2, polymorphic: true + assert column_exists?(table_name, :taggable_id, :integer, limit: 2) + assert column_exists?(table_name, :taggable_type, :string) + assert_not column_exists?(table_name, :taggable_type, :string, limit: 2) + end + def test_creates_named_index add_reference table_name, :tag, index: { name: "index_taggings_on_tag_id" } assert index_exists?(table_name, :tag_id, name: "index_taggings_on_tag_id") diff --git a/activerecord/test/cases/migration/rename_table_test.rb b/activerecord/test/cases/migration/rename_table_test.rb index 19588d28a2..a9deb92585 100644 --- a/activerecord/test/cases/migration/rename_table_test.rb +++ b/activerecord/test/cases/migration/rename_table_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/migration/helper" module ActiveRecord @@ -79,10 +81,33 @@ module ActiveRecord assert_equal ConnectionAdapters::PostgreSQL::Name.new("public", "octopi_#{pk}_seq"), seq end + def test_renaming_table_renames_primary_key + connection.create_table :cats, id: :uuid, default: "uuid_generate_v4()" + rename_table :cats, :felines + + assert connection.table_exists? :felines + assert_not connection.table_exists? :cats + + primary_key_name = connection.select_values(<<~SQL, "SCHEMA")[0] + SELECT c.relname + FROM pg_class c + JOIN pg_index i + ON c.oid = i.indexrelid + WHERE i.indisprimary + AND i.indrelid = 'felines'::regclass + SQL + + assert_equal "felines_pkey", primary_key_name + ensure + connection.drop_table :cats, if_exists: true + connection.drop_table :felines, if_exists: true + end + def test_renaming_table_doesnt_attempt_to_rename_non_existent_sequences - connection.create_table :cats, id: :uuid + connection.create_table :cats, id: :uuid, default: "uuid_generate_v4()" assert_nothing_raised { rename_table :cats, :felines } assert connection.table_exists? :felines + assert_not connection.table_exists? :cats ensure connection.drop_table :cats, if_exists: true connection.drop_table :felines, if_exists: true diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index fabb1662a3..868bb40ab2 100644 --- a/activerecord/test/cases/migration_test.rb +++ b/activerecord/test/cases/migration_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "cases/migration/helper" require "bigdecimal/util" @@ -69,6 +71,16 @@ class MigrationTest < ActiveRecord::TestCase ActiveRecord::Migration.verbose = @verbose_was end + def test_migrator_migrations_path_is_deprecated + assert_deprecated do + ActiveRecord::Migrator.migrations_path = "/whatever" + end + ensure + assert_deprecated do + ActiveRecord::Migrator.migrations_path = "db/migrate" + end + end + def test_migration_version_matches_component_version assert_equal ActiveRecord::VERSION::STRING.to_f, ActiveRecord::Migration.current_version end @@ -76,20 +88,20 @@ class MigrationTest < ActiveRecord::TestCase def test_migrator_versions migrations_path = MIGRATIONS_ROOT + "/valid" old_path = ActiveRecord::Migrator.migrations_paths - ActiveRecord::Migrator.migrations_paths = migrations_path + migrator = ActiveRecord::MigrationContext.new(migrations_path) - ActiveRecord::Migrator.up(migrations_path) - assert_equal 3, ActiveRecord::Migrator.current_version - assert_equal false, ActiveRecord::Migrator.needs_migration? + migrator.up + assert_equal 3, migrator.current_version + assert_equal false, migrator.needs_migration? - ActiveRecord::Migrator.down(MIGRATIONS_ROOT + "/valid") - assert_equal 0, ActiveRecord::Migrator.current_version - assert_equal true, ActiveRecord::Migrator.needs_migration? + migrator.down + assert_equal 0, migrator.current_version + assert_equal true, migrator.needs_migration? ActiveRecord::SchemaMigration.create!(version: 3) - assert_equal true, ActiveRecord::Migrator.needs_migration? + assert_equal true, migrator.needs_migration? ensure - ActiveRecord::Migrator.migrations_paths = old_path + ActiveRecord::MigrationContext.new(old_path) end def test_migration_detection_without_schema_migration_table @@ -97,28 +109,31 @@ class MigrationTest < ActiveRecord::TestCase migrations_path = MIGRATIONS_ROOT + "/valid" old_path = ActiveRecord::Migrator.migrations_paths - ActiveRecord::Migrator.migrations_paths = migrations_path + migrator = ActiveRecord::MigrationContext.new(migrations_path) - assert_equal true, ActiveRecord::Migrator.needs_migration? + assert_equal true, migrator.needs_migration? ensure - ActiveRecord::Migrator.migrations_paths = old_path + ActiveRecord::MigrationContext.new(old_path) end def test_any_migrations old_path = ActiveRecord::Migrator.migrations_paths - ActiveRecord::Migrator.migrations_paths = MIGRATIONS_ROOT + "/valid" + migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid") - assert ActiveRecord::Migrator.any_migrations? + assert_predicate migrator, :any_migrations? - ActiveRecord::Migrator.migrations_paths = MIGRATIONS_ROOT + "/empty" + migrator_empty = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/empty") - assert_not ActiveRecord::Migrator.any_migrations? + assert_not_predicate migrator_empty, :any_migrations? ensure - ActiveRecord::Migrator.migrations_paths = old_path + ActiveRecord::MigrationContext.new(old_path) end def test_migration_version - assert_nothing_raised { ActiveRecord::Migrator.run(:up, MIGRATIONS_ROOT + "/version_check", 20131219224947) } + migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/version_check") + assert_equal 0, migrator.current_version + migrator.up(20131219224947) + assert_equal 20131219224947, migrator.current_version end def test_create_table_with_force_true_does_not_drop_nonexisting_table @@ -155,14 +170,14 @@ class MigrationTest < ActiveRecord::TestCase def test_add_table_with_decimals Person.connection.drop_table :big_numbers rescue nil - assert !BigNumber.table_exists? + assert_not_predicate BigNumber, :table_exists? GiveMeBigNumbers.up BigNumber.reset_column_information assert BigNumber.create( bank_balance: 1586.43, big_bank_balance: BigDecimal("1000234000567.95"), - world_population: 6000000000, + world_population: 2**62, my_house_population: 3, value_of_e: BigDecimal("2.7182818284590452353602875") ) @@ -176,10 +191,8 @@ class MigrationTest < ActiveRecord::TestCase assert_not_nil b.my_house_population assert_not_nil b.value_of_e - # TODO: set world_population >= 2**62 to cover 64-bit platforms and test - # is_a?(Bignum) assert_kind_of Integer, b.world_population - assert_equal 6000000000, b.world_population + assert_equal 2**62, b.world_population assert_kind_of Integer, b.my_house_population assert_equal 3, b.my_house_population assert_kind_of BigDecimal, b.bank_balance @@ -214,15 +227,16 @@ class MigrationTest < ActiveRecord::TestCase def test_filtering_migrations assert_no_column Person, :last_name - assert !Reminder.table_exists? + assert_not_predicate Reminder, :table_exists? name_filter = lambda { |migration| migration.name == "ValidPeopleHaveLastNames" } - ActiveRecord::Migrator.up(MIGRATIONS_ROOT + "/valid", &name_filter) + migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid") + migrator.up(&name_filter) assert_column Person, :last_name assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } - ActiveRecord::Migrator.down(MIGRATIONS_ROOT + "/valid", &name_filter) + migrator.down(&name_filter) assert_no_column Person, :last_name assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } @@ -248,21 +262,21 @@ class MigrationTest < ActiveRecord::TestCase def test_instance_based_migration_up migration = MockMigration.new - assert !migration.went_up, "have not gone up" - assert !migration.went_down, "have not gone down" + assert_not migration.went_up, "have not gone up" + assert_not migration.went_down, "have not gone down" migration.migrate :up assert migration.went_up, "have gone up" - assert !migration.went_down, "have not gone down" + assert_not migration.went_down, "have not gone down" end def test_instance_based_migration_down migration = MockMigration.new - assert !migration.went_up, "have not gone up" - assert !migration.went_down, "have not gone down" + assert_not migration.went_up, "have not gone up" + assert_not migration.went_down, "have not gone down" migration.migrate :down - assert !migration.went_up, "have gone up" + assert_not migration.went_up, "have gone up" assert migration.went_down, "have not gone down" end @@ -337,20 +351,20 @@ class MigrationTest < ActiveRecord::TestCase end def test_schema_migrations_table_name - original_schema_migrations_table_name = ActiveRecord::Migrator.schema_migrations_table_name + original_schema_migrations_table_name = ActiveRecord::Base.schema_migrations_table_name - assert_equal "schema_migrations", ActiveRecord::Migrator.schema_migrations_table_name + assert_equal "schema_migrations", ActiveRecord::SchemaMigration.table_name ActiveRecord::Base.table_name_prefix = "prefix_" ActiveRecord::Base.table_name_suffix = "_suffix" Reminder.reset_table_name - assert_equal "prefix_schema_migrations_suffix", ActiveRecord::Migrator.schema_migrations_table_name + assert_equal "prefix_schema_migrations_suffix", ActiveRecord::SchemaMigration.table_name ActiveRecord::Base.schema_migrations_table_name = "changed" Reminder.reset_table_name - assert_equal "prefix_changed_suffix", ActiveRecord::Migrator.schema_migrations_table_name + assert_equal "prefix_changed_suffix", ActiveRecord::SchemaMigration.table_name ActiveRecord::Base.table_name_prefix = "" ActiveRecord::Base.table_name_suffix = "" Reminder.reset_table_name - assert_equal "changed", ActiveRecord::Migrator.schema_migrations_table_name + assert_equal "changed", ActiveRecord::SchemaMigration.table_name ensure ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name Reminder.reset_table_name @@ -380,9 +394,9 @@ class MigrationTest < ActiveRecord::TestCase current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call migrations_path = MIGRATIONS_ROOT + "/valid" old_path = ActiveRecord::Migrator.migrations_paths - ActiveRecord::Migrator.migrations_paths = migrations_path + migrator = ActiveRecord::MigrationContext.new(migrations_path) - ActiveRecord::Migrator.up(migrations_path) + migrator.up assert_equal current_env, ActiveRecord::InternalMetadata[:environment] original_rails_env = ENV["RAILS_ENV"] @@ -390,43 +404,16 @@ class MigrationTest < ActiveRecord::TestCase ENV["RAILS_ENV"] = ENV["RACK_ENV"] = "foofoo" new_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - refute_equal current_env, new_env + assert_not_equal current_env, new_env sleep 1 # mysql by default does not store fractional seconds in the database - ActiveRecord::Migrator.up(migrations_path) + migrator.up assert_equal new_env, ActiveRecord::InternalMetadata[:environment] ensure - ActiveRecord::Migrator.migrations_paths = old_path + migrator = ActiveRecord::MigrationContext.new(old_path) ENV["RAILS_ENV"] = original_rails_env ENV["RACK_ENV"] = original_rack_env - ActiveRecord::Migrator.up(migrations_path) - end - - def test_migration_sets_internal_metadata_even_when_fully_migrated - current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - migrations_path = MIGRATIONS_ROOT + "/valid" - old_path = ActiveRecord::Migrator.migrations_paths - ActiveRecord::Migrator.migrations_paths = migrations_path - - ActiveRecord::Migrator.up(migrations_path) - assert_equal current_env, ActiveRecord::InternalMetadata[:environment] - - original_rails_env = ENV["RAILS_ENV"] - original_rack_env = ENV["RACK_ENV"] - ENV["RAILS_ENV"] = ENV["RACK_ENV"] = "foofoo" - new_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - - refute_equal current_env, new_env - - sleep 1 # mysql by default does not store fractional seconds in the database - - ActiveRecord::Migrator.up(migrations_path) - assert_equal new_env, ActiveRecord::InternalMetadata[:environment] - ensure - ActiveRecord::Migrator.migrations_paths = old_path - ENV["RAILS_ENV"] = original_rails_env - ENV["RACK_ENV"] = original_rack_env - ActiveRecord::Migrator.up(migrations_path) + migrator.up end def test_internal_metadata_stores_environment_when_other_data_exists @@ -436,14 +423,15 @@ class MigrationTest < ActiveRecord::TestCase current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call migrations_path = MIGRATIONS_ROOT + "/valid" old_path = ActiveRecord::Migrator.migrations_paths - ActiveRecord::Migrator.migrations_paths = migrations_path current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - ActiveRecord::Migrator.up(migrations_path) + migrator = ActiveRecord::MigrationContext.new(migrations_path) + migrator.up assert_equal current_env, ActiveRecord::InternalMetadata[:environment] assert_equal "bar", ActiveRecord::InternalMetadata[:foo] ensure - ActiveRecord::Migrator.migrations_paths = old_path + migrator = ActiveRecord::MigrationContext.new(old_path) + migrator.up end def test_proper_table_name_on_migration @@ -475,7 +463,7 @@ class MigrationTest < ActiveRecord::TestCase end def test_rename_table_with_prefix_and_suffix - assert !Thing.table_exists? + assert_not_predicate Thing, :table_exists? ActiveRecord::Base.table_name_prefix = "p_" ActiveRecord::Base.table_name_suffix = "_s" Thing.reset_table_name @@ -496,7 +484,7 @@ class MigrationTest < ActiveRecord::TestCase end def test_add_drop_table_with_prefix_and_suffix - assert !Reminder.table_exists? + assert_not_predicate Reminder, :table_exists? ActiveRecord::Base.table_name_prefix = "prefix_" ActiveRecord::Base.table_name_suffix = "_suffix" Reminder.reset_table_name @@ -529,11 +517,10 @@ class MigrationTest < ActiveRecord::TestCase unless mysql_enforcing_gtid_consistency? def test_create_table_with_query - Person.connection.create_table(:person, force: true) - - Person.connection.create_table :table_from_query_testings, as: "SELECT id FROM person" + Person.connection.create_table :table_from_query_testings, as: "SELECT id FROM people WHERE id = 1" columns = Person.connection.columns(:table_from_query_testings) + assert_equal [1], Person.connection.select_values("SELECT * FROM table_from_query_testings") assert_equal 1, columns.length assert_equal "id", columns.first.name ensure @@ -541,11 +528,10 @@ class MigrationTest < ActiveRecord::TestCase end def test_create_table_with_query_from_relation - Person.connection.create_table(:person, force: true) - - Person.connection.create_table :table_from_query_testings, as: Person.select(:id) + Person.connection.create_table :table_from_query_testings, as: Person.select(:id).where(id: 1) columns = Person.connection.columns(:table_from_query_testings) + assert_equal [1], Person.connection.select_values("SELECT * FROM table_from_query_testings") assert_equal 1, columns.length assert_equal "id", columns.first.name ensure @@ -562,7 +548,7 @@ class MigrationTest < ActiveRecord::TestCase end assert Person.connection.column_exists?(:something, :foo) assert_nothing_raised { Person.connection.remove_column :something, :foo, :bar } - assert !Person.connection.column_exists?(:something, :foo) + assert_not Person.connection.column_exists?(:something, :foo) assert Person.connection.column_exists?(:something, :name) assert Person.connection.column_exists?(:something, :number) ensure @@ -705,6 +691,25 @@ class MigrationTest < ActiveRecord::TestCase assert_no_column Person, :last_name, "without an advisory lock, the Migrator should not make any changes, but it did." end + + def test_with_advisory_lock_raises_the_right_error_when_it_fails_to_release_lock + migration = Class.new(ActiveRecord::Migration::Current).new + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + lock_id = migrator.send(:generate_migrator_advisory_lock_id) + + e = assert_raises(ActiveRecord::ConcurrentMigrationError) do + silence_stream($stderr) do + migrator.send(:with_advisory_lock) do + ActiveRecord::Base.connection.release_advisory_lock(lock_id) + end + end + end + + assert_match( + /#{ActiveRecord::ConcurrentMigrationError::RELEASE_LOCK_FAILED_MESSAGE}/, + e.message + ) + end end private @@ -788,12 +793,20 @@ if ActiveRecord::Base.connection.supports_bulk_alter? end def test_adding_multiple_columns - assert_queries(1) do + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 1, + "PostgreSQLAdapter" => 2, # one for bulk change, one for comment + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count) do with_bulk_change_table do |t| t.column :name, :string t.string :qualification, :experience t.integer :age, default: 0 - t.date :birthdate + t.date :birthdate, comment: "This is a comment" t.timestamps null: true end end @@ -801,6 +814,7 @@ if ActiveRecord::Base.connection.supports_bulk_alter? assert_equal 8, columns.size [:name, :qualification, :experience].each { |s| assert_equal :string, column(s).type } assert_equal "0", column(:age).default + assert_equal "This is a comment", column(:birthdate).comment end def test_removing_columns @@ -817,7 +831,7 @@ if ActiveRecord::Base.connection.supports_bulk_alter? end end - [:qualification, :experience].each { |c| assert ! column(c) } + [:qualification, :experience].each { |c| assert_not column(c) } assert column(:qualification_experience) end @@ -828,8 +842,15 @@ if ActiveRecord::Base.connection.supports_bulk_alter? t.integer :age end - # Adding an index fires a query every time to check if an index already exists or not - assert_queries(3) do + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not + "PostgreSQLAdapter" => 2, + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count) do with_bulk_change_table do |t| t.index :username, unique: true, name: :awesome_username_index t.index [:name, :age] @@ -840,7 +861,7 @@ if ActiveRecord::Base.connection.supports_bulk_alter? name_age_index = index(:index_delete_me_on_name_and_age) assert_equal ["name", "age"].sort, name_age_index.columns.sort - assert ! name_age_index.unique + assert_not name_age_index.unique assert index(:awesome_username_index).unique end @@ -853,14 +874,22 @@ if ActiveRecord::Base.connection.supports_bulk_alter? assert index(:index_delete_me_on_name) - assert_queries(3) do + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not + "PostgreSQLAdapter" => 2, + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count) do with_bulk_change_table do |t| t.remove_index :name t.index :name, name: :new_name_index, unique: true end end - assert ! index(:index_delete_me_on_name) + assert_not index(:index_delete_me_on_name) new_name_index = index(:new_name_index) assert new_name_index.unique @@ -872,21 +901,27 @@ if ActiveRecord::Base.connection.supports_bulk_alter? t.date :birthdate end - assert ! column(:name).default + assert_not column(:name).default assert_equal :date, column(:birthdate).type - # One query for columns (delete_me table) - # One query for primary key (delete_me table) - # One query to do the bulk change - assert_queries(3, ignore_none: true) do + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 3, # one query for columns, one query for primary key, one query to do the bulk change + "PostgreSQLAdapter" => 3, # one query for columns, one for bulk change, one for comment + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count, ignore_none: true) do with_bulk_change_table do |t| t.change :name, :string, default: "NONAME" - t.change :birthdate, :datetime + t.change :birthdate, :datetime, comment: "This is a comment" end end assert_equal "NONAME", column(:name).default assert_equal :datetime, column(:birthdate).type + assert_equal "This is a comment", column(:birthdate).comment end private @@ -941,12 +976,12 @@ class CopyMigrationsTest < ActiveRecord::TestCase assert_equal [@migrations_path + "/4_people_have_hobbies.bukkits.rb", @migrations_path + "/5_people_have_descriptions.bukkits.rb"], copied.map(&:filename) expected = "# This migration comes from bukkits (originally 1)" - assert_equal expected, IO.readlines(@migrations_path + "/4_people_have_hobbies.bukkits.rb")[0].chomp + assert_equal expected, IO.readlines(@migrations_path + "/4_people_have_hobbies.bukkits.rb")[1].chomp files_count = Dir[@migrations_path + "/*.rb"].length copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy") assert_equal files_count, Dir[@migrations_path + "/*.rb"].length - assert copied.empty? + assert_empty copied ensure clear end @@ -987,7 +1022,7 @@ class CopyMigrationsTest < ActiveRecord::TestCase files_count = Dir[@migrations_path + "/*.rb"].length copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps") assert_equal files_count, Dir[@migrations_path + "/*.rb"].length - assert copied.empty? + assert_empty copied end ensure clear @@ -1029,7 +1064,7 @@ class CopyMigrationsTest < ActiveRecord::TestCase files_count = Dir[@migrations_path + "/*.rb"].length copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps") assert_equal files_count, Dir[@migrations_path + "/*.rb"].length - assert copied.empty? + assert_empty copied end ensure clear @@ -1044,13 +1079,13 @@ class CopyMigrationsTest < ActiveRecord::TestCase assert File.exist?(@migrations_path + "/4_currencies_have_symbols.bukkits.rb") assert_equal [@migrations_path + "/4_currencies_have_symbols.bukkits.rb"], copied.map(&:filename) - expected = "# coding: ISO-8859-15\n# This migration comes from bukkits (originally 1)" - assert_equal expected, IO.readlines(@migrations_path + "/4_currencies_have_symbols.bukkits.rb")[0..1].join.chomp + expected = "# frozen_string_literal: true\n# coding: ISO-8859-15\n# This migration comes from bukkits (originally 1)" + assert_equal expected, IO.readlines(@migrations_path + "/4_currencies_have_symbols.bukkits.rb")[0..2].join.chomp files_count = Dir[@migrations_path + "/*.rb"].length copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/magic") assert_equal files_count, Dir[@migrations_path + "/*.rb"].length - assert copied.empty? + assert_empty copied ensure clear end @@ -1133,17 +1168,4 @@ class CopyMigrationsTest < ActiveRecord::TestCase def test_unknown_migration_version_should_raise_an_argument_error assert_raise(ArgumentError) { ActiveRecord::Migration[1.0] } end - - def test_deprecate_initialize_internal_tables - assert_deprecated { ActiveRecord::Base.connection.initialize_schema_migrations_table } - assert_deprecated { ActiveRecord::Base.connection.initialize_internal_metadata_table } - end - - def test_deprecate_migration_keys - assert_deprecated { ActiveRecord::Base.connection.migration_keys } - end - - def test_deprecate_supports_migrations - assert_deprecated { ActiveRecord::Base.connection.supports_migrations? } - end end diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb index 20d70b75ac..873455cf67 100644 --- a/activerecord/test/cases/migrator_test.rb +++ b/activerecord/test/cases/migrator_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "cases/migration/helper" @@ -64,10 +66,30 @@ class MigratorTest < ActiveRecord::TestCase list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] ActiveRecord::Migrator.new(:up, list, 3).run end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, -1).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 0).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 3).migrate + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, -1).migrate + end end def test_finds_migrations - migrations = ActiveRecord::Migrator.migrations(MIGRATIONS_ROOT + "/valid") + migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid").migrations [[1, "ValidPeopleHaveLastNames"], [2, "WeNeedReminders"], [3, "InnocentJointable"]].each_with_index do |pair, i| assert_equal migrations[i].version, pair.first @@ -76,7 +98,8 @@ class MigratorTest < ActiveRecord::TestCase end def test_finds_migrations_in_subdirectories - migrations = ActiveRecord::Migrator.migrations(MIGRATIONS_ROOT + "/valid_with_subdirectories") + migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid_with_subdirectories").migrations + [[1, "ValidPeopleHaveLastNames"], [2, "WeNeedReminders"], [3, "InnocentJointable"]].each_with_index do |pair, i| assert_equal migrations[i].version, pair.first @@ -86,7 +109,7 @@ class MigratorTest < ActiveRecord::TestCase def test_finds_migrations_from_two_directories directories = [MIGRATIONS_ROOT + "/valid_with_timestamps", MIGRATIONS_ROOT + "/to_copy_with_timestamps"] - migrations = ActiveRecord::Migrator.migrations directories + migrations = ActiveRecord::MigrationContext.new(directories).migrations [[20090101010101, "PeopleHaveHobbies"], [20090101010202, "PeopleHaveDescriptions"], @@ -99,14 +122,14 @@ class MigratorTest < ActiveRecord::TestCase end def test_finds_migrations_in_numbered_directory - migrations = ActiveRecord::Migrator.migrations [MIGRATIONS_ROOT + "/10_urban"] + migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/10_urban").migrations assert_equal 9, migrations[0].version assert_equal "AddExpressions", migrations[0].name end def test_relative_migrations list = Dir.chdir(MIGRATIONS_ROOT) do - ActiveRecord::Migrator.migrations("valid") + ActiveRecord::MigrationContext.new("valid").migrations end migration_proxy = list.find { |item| @@ -124,6 +147,67 @@ class MigratorTest < ActiveRecord::TestCase assert_equal migration_list.last, migrations.first end + def test_migrations_status + path = MIGRATIONS_ROOT + "/valid" + + ActiveRecord::SchemaMigration.create(version: 2) + ActiveRecord::SchemaMigration.create(version: 10) + + assert_equal [ + ["down", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["down", "003", "Innocent jointable"], + ["up", "010", "********** NO FILE **********"], + ], ActiveRecord::MigrationContext.new(path).migrations_status + end + + def test_migrations_status_in_subdirectories + path = MIGRATIONS_ROOT + "/valid_with_subdirectories" + + ActiveRecord::SchemaMigration.create(version: 2) + ActiveRecord::SchemaMigration.create(version: 10) + + assert_equal [ + ["down", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["down", "003", "Innocent jointable"], + ["up", "010", "********** NO FILE **********"], + ], ActiveRecord::MigrationContext.new(path).migrations_status + end + + def test_migrations_status_with_schema_define_in_subdirectories + path = MIGRATIONS_ROOT + "/valid_with_subdirectories" + prev_paths = ActiveRecord::Migrator.migrations_paths + ActiveRecord::Migrator.migrations_paths = path + + ActiveRecord::Schema.define(version: 3) do + end + + assert_equal [ + ["up", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["up", "003", "Innocent jointable"], + ], ActiveRecord::MigrationContext.new(path).migrations_status + ensure + ActiveRecord::Migrator.migrations_paths = prev_paths + end + + def test_migrations_status_from_two_directories + paths = [MIGRATIONS_ROOT + "/valid_with_timestamps", MIGRATIONS_ROOT + "/to_copy_with_timestamps"] + + ActiveRecord::SchemaMigration.create(version: "20100101010101") + ActiveRecord::SchemaMigration.create(version: "20160528010101") + + assert_equal [ + ["down", "20090101010101", "People have hobbies"], + ["down", "20090101010202", "People have descriptions"], + ["up", "20100101010101", "Valid with timestamps people have last names"], + ["down", "20100201010101", "Valid with timestamps we need reminders"], + ["down", "20100301010101", "Valid with timestamps innocent jointable"], + ["up", "20160528010101", "********** NO FILE **********"], + ], ActiveRecord::MigrationContext.new(paths).migrations_status + end + def test_migrator_interleaved_migrations pass_one = [Sensor.new("One", 1)] @@ -149,25 +233,28 @@ class MigratorTest < ActiveRecord::TestCase def test_up_calls_up migrations = [Sensor.new(nil, 0), Sensor.new(nil, 1), Sensor.new(nil, 2)] - ActiveRecord::Migrator.new(:up, migrations).migrate + migrator = ActiveRecord::Migrator.new(:up, migrations) + migrator.migrate assert migrations.all?(&:went_up) assert migrations.all? { |m| !m.went_down } - assert_equal 2, ActiveRecord::Migrator.current_version + assert_equal 2, migrator.current_version end def test_down_calls_down test_up_calls_up migrations = [Sensor.new(nil, 0), Sensor.new(nil, 1), Sensor.new(nil, 2)] - ActiveRecord::Migrator.new(:down, migrations).migrate + migrator = ActiveRecord::Migrator.new(:down, migrations) + migrator.migrate assert migrations.all? { |m| !m.went_up } assert migrations.all?(&:went_down) - assert_equal 0, ActiveRecord::Migrator.current_version + assert_equal 0, migrator.current_version end def test_current_version ActiveRecord::SchemaMigration.create!(version: "1000") - assert_equal 1000, ActiveRecord::Migrator.current_version + migrator = ActiveRecord::MigrationContext.new("db/migrate") + assert_equal 1000, migrator.current_version end def test_migrator_one_up @@ -206,38 +293,42 @@ class MigratorTest < ActiveRecord::TestCase def test_migrator_double_up calls, migrations = sensors(3) - assert_equal(0, ActiveRecord::Migrator.current_version) + migrator = ActiveRecord::Migrator.new(:up, migrations, 1) + assert_equal(0, migrator.current_version) - ActiveRecord::Migrator.new(:up, migrations, 1).migrate + migrator.migrate assert_equal [[:up, 1]], calls calls.clear - ActiveRecord::Migrator.new(:up, migrations, 1).migrate + migrator.migrate assert_equal [], calls end def test_migrator_double_down calls, migrations = sensors(3) + migrator = ActiveRecord::Migrator.new(:up, migrations, 1) - assert_equal(0, ActiveRecord::Migrator.current_version) + assert_equal 0, migrator.current_version - ActiveRecord::Migrator.new(:up, migrations, 1).run + migrator.run assert_equal [[:up, 1]], calls calls.clear - ActiveRecord::Migrator.new(:down, migrations, 1).run + migrator = ActiveRecord::Migrator.new(:down, migrations, 1) + migrator.run assert_equal [[:down, 1]], calls calls.clear - ActiveRecord::Migrator.new(:down, migrations, 1).run + migrator.run assert_equal [], calls - assert_equal(0, ActiveRecord::Migrator.current_version) + assert_equal 0, migrator.current_version end def test_migrator_verbosity _, migrations = sensors(3) + ActiveRecord::Migration.verbose = true ActiveRecord::Migrator.new(:up, migrations, 1).migrate assert_not_equal 0, ActiveRecord::Migration.message_count @@ -250,7 +341,6 @@ class MigratorTest < ActiveRecord::TestCase def test_migrator_verbosity_off _, migrations = sensors(3) - ActiveRecord::Migration.message_count = 0 ActiveRecord::Migration.verbose = false ActiveRecord::Migrator.new(:up, migrations, 1).migrate assert_equal 0, ActiveRecord::Migration.message_count @@ -278,78 +368,85 @@ class MigratorTest < ActiveRecord::TestCase def test_migrator_going_down_due_to_version_target calls, migrator = migrator_class(3) + migrator = migrator.new("valid") - migrator.up("valid", 1) + migrator.up(1) assert_equal [[:up, 1]], calls calls.clear - migrator.migrate("valid", 0) + migrator.migrate(0) assert_equal [[:down, 1]], calls calls.clear - migrator.migrate("valid") + migrator.migrate assert_equal [[:up, 1], [:up, 2], [:up, 3]], calls end def test_migrator_output_when_running_multiple_migrations _, migrator = migrator_class(3) + migrator = migrator.new("valid") - result = migrator.migrate("valid") + result = migrator.migrate assert_equal(3, result.count) # Nothing migrated from duplicate run - result = migrator.migrate("valid") + result = migrator.migrate assert_equal(0, result.count) - result = migrator.rollback("valid") + result = migrator.rollback assert_equal(1, result.count) end def test_migrator_output_when_running_single_migration _, migrator = migrator_class(1) - result = migrator.run(:up, "valid", 1) + migrator = migrator.new("valid") + + result = migrator.run(:up, 1) assert_equal(1, result.version) end def test_migrator_rollback _, migrator = migrator_class(3) + migrator = migrator.new("valid") - migrator.migrate("valid") - assert_equal(3, ActiveRecord::Migrator.current_version) + migrator.migrate + assert_equal(3, migrator.current_version) - migrator.rollback("valid") - assert_equal(2, ActiveRecord::Migrator.current_version) + migrator.rollback + assert_equal(2, migrator.current_version) - migrator.rollback("valid") - assert_equal(1, ActiveRecord::Migrator.current_version) + migrator.rollback + assert_equal(1, migrator.current_version) - migrator.rollback("valid") - assert_equal(0, ActiveRecord::Migrator.current_version) + migrator.rollback + assert_equal(0, migrator.current_version) - migrator.rollback("valid") - assert_equal(0, ActiveRecord::Migrator.current_version) + migrator.rollback + assert_equal(0, migrator.current_version) end def test_migrator_db_has_no_schema_migrations_table _, migrator = migrator_class(3) + migrator = migrator.new("valid") ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true assert_not ActiveRecord::Base.connection.table_exists?("schema_migrations") - migrator.migrate("valid", 1) + migrator.migrate(1) assert ActiveRecord::Base.connection.table_exists?("schema_migrations") end def test_migrator_forward _, migrator = migrator_class(3) - migrator.migrate("/valid", 1) - assert_equal(1, ActiveRecord::Migrator.current_version) + migrator = migrator.new("/valid") + migrator.migrate(1) + assert_equal(1, migrator.current_version) - migrator.forward("/valid", 2) - assert_equal(3, ActiveRecord::Migrator.current_version) + migrator.forward(2) + assert_equal(3, migrator.current_version) - migrator.forward("/valid") - assert_equal(3, ActiveRecord::Migrator.current_version) + migrator.forward + assert_equal(3, migrator.current_version) end def test_only_loads_pending_migrations @@ -357,25 +454,27 @@ class MigratorTest < ActiveRecord::TestCase ActiveRecord::SchemaMigration.create!(version: "1") calls, migrator = migrator_class(3) - migrator.migrate("valid", nil) + migrator = migrator.new("valid") + migrator.migrate assert_equal [[:up, 2], [:up, 3]], calls end def test_get_all_versions _, migrator = migrator_class(3) + migrator = migrator.new("valid") - migrator.migrate("valid") - assert_equal([1, 2, 3], ActiveRecord::Migrator.get_all_versions) + migrator.migrate + assert_equal([1, 2, 3], migrator.get_all_versions) - migrator.rollback("valid") - assert_equal([1, 2], ActiveRecord::Migrator.get_all_versions) + migrator.rollback + assert_equal([1, 2], migrator.get_all_versions) - migrator.rollback("valid") - assert_equal([1], ActiveRecord::Migrator.get_all_versions) + migrator.rollback + assert_equal([1], migrator.get_all_versions) - migrator.rollback("valid") - assert_equal([], ActiveRecord::Migrator.get_all_versions) + migrator.rollback + assert_equal([], migrator.get_all_versions) end private @@ -400,11 +499,11 @@ class MigratorTest < ActiveRecord::TestCase def migrator_class(count) calls, migrations = sensors(count) - migrator = Class.new(ActiveRecord::Migrator).extend(Module.new { - define_method(:migrations) { |paths| + migrator = Class.new(ActiveRecord::MigrationContext) { + define_method(:migrations) { |*| migrations } - }) + } [calls, migrator] end end diff --git a/activerecord/test/cases/mixin_test.rb b/activerecord/test/cases/mixin_test.rb index a8af8e30f7..fdb8ac6ab3 100644 --- a/activerecord/test/cases/mixin_test.rb +++ b/activerecord/test/cases/mixin_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class Mixin < ActiveRecord::Base @@ -10,10 +12,6 @@ class TouchTest < ActiveRecord::TestCase travel_to Time.now end - teardown do - travel_back - end - def test_update stamped = Mixin.new diff --git a/activerecord/test/cases/modules_test.rb b/activerecord/test/cases/modules_test.rb index f8a7bab35f..87455e4fcb 100644 --- a/activerecord/test/cases/modules_test.rb +++ b/activerecord/test/cases/modules_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/company_in_module" require "models/shop" @@ -30,7 +32,7 @@ class ModulesTest < ActiveRecord::TestCase def test_module_spanning_associations firm = MyApplication::Business::Firm.first - assert !firm.clients.empty?, "Firm should have clients" + assert_not firm.clients.empty?, "Firm should have clients" assert_nil firm.class.table_name.match("::"), "Firm shouldn't have the module appear in its table name" end @@ -153,7 +155,7 @@ class ModulesTest < ActiveRecord::TestCase ActiveRecord::Base.store_full_sti_class = true collection = Shop::Collection.first - assert !collection.products.empty?, "Collection should have products" + assert_not collection.products.empty?, "Collection should have products" assert_nothing_raised { collection.destroy } ensure ActiveRecord::Base.store_full_sti_class = old @@ -164,7 +166,7 @@ class ModulesTest < ActiveRecord::TestCase ActiveRecord::Base.store_full_sti_class = true product = Shop::Product.first - assert !product.variants.empty?, "Product should have variants" + assert_not product.variants.empty?, "Product should have variants" assert_nothing_raised { product.destroy } ensure ActiveRecord::Base.store_full_sti_class = old diff --git a/activerecord/test/cases/multiparameter_attributes_test.rb b/activerecord/test/cases/multiparameter_attributes_test.rb index ceb5724377..6f3903eed4 100644 --- a/activerecord/test/cases/multiparameter_attributes_test.rb +++ b/activerecord/test/cases/multiparameter_attributes_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/customer" @@ -225,7 +227,7 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase topic = Topic.find(1) topic.attributes = attributes assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on - assert_equal false, topic.written_on.respond_to?(:time_zone) + assert_not_respond_to topic.written_on, :time_zone end end @@ -240,7 +242,7 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase topic = Topic.find(1) topic.attributes = attributes assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on - assert_equal false, topic.written_on.respond_to?(:time_zone) + assert_not_respond_to topic.written_on, :time_zone end ensure Topic.skip_time_zone_conversion_for_attributes = [] @@ -259,7 +261,7 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase topic = Topic.find(1) topic.attributes = attributes assert_equal Time.zone.local(2000, 1, 1, 16, 24, 0), topic.bonus_time - assert_not topic.bonus_time.utc? + assert_not_predicate topic.bonus_time, :utc? attributes = { "written_on(1i)" => "2000", "written_on(2i)" => "", "written_on(3i)" => "", @@ -271,6 +273,12 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase ensure Topic.reset_column_information end + + def test_multiparameter_attributes_setting_time_attribute + topic = Topic.new("bonus_time(4i)" => "01", "bonus_time(5i)" => "05") + assert_equal 1, topic.bonus_time.hour + assert_equal 5, topic.bonus_time.min + end end def test_multiparameter_attributes_on_time_with_empty_seconds @@ -285,14 +293,6 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase end end - unless current_adapter? :OracleAdapter - def test_multiparameter_attributes_setting_time_attribute - topic = Topic.new("bonus_time(4i)" => "01", "bonus_time(5i)" => "05") - assert_equal 1, topic.bonus_time.hour - assert_equal 5, topic.bonus_time.min - end - end - def test_multiparameter_attributes_setting_date_attribute topic = Topic.new("written_on(1i)" => "1952", "written_on(2i)" => "3", "written_on(3i)" => "11") assert_equal 1952, topic.written_on.year @@ -300,13 +300,34 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase assert_equal 11, topic.written_on.day end + def test_create_with_multiparameter_attributes_setting_date_attribute + topic = Topic.create_with("written_on(1i)" => "1952", "written_on(2i)" => "3", "written_on(3i)" => "11").new + assert_equal 1952, topic.written_on.year + assert_equal 3, topic.written_on.month + assert_equal 11, topic.written_on.day + end + def test_multiparameter_attributes_setting_date_and_time_attribute topic = Topic.new( - "written_on(1i)" => "1952", - "written_on(2i)" => "3", - "written_on(3i)" => "11", - "written_on(4i)" => "13", - "written_on(5i)" => "55") + "written_on(1i)" => "1952", + "written_on(2i)" => "3", + "written_on(3i)" => "11", + "written_on(4i)" => "13", + "written_on(5i)" => "55") + assert_equal 1952, topic.written_on.year + assert_equal 3, topic.written_on.month + assert_equal 11, topic.written_on.day + assert_equal 13, topic.written_on.hour + assert_equal 55, topic.written_on.min + end + + def test_create_with_multiparameter_attributes_setting_date_and_time_attribute + topic = Topic.create_with( + "written_on(1i)" => "1952", + "written_on(2i)" => "3", + "written_on(3i)" => "11", + "written_on(4i)" => "13", + "written_on(5i)" => "55").new assert_equal 1952, topic.written_on.year assert_equal 3, topic.written_on.month assert_equal 11, topic.written_on.day @@ -364,4 +385,15 @@ class MultiParameterAttributeTest < ActiveRecord::TestCase assert_equal("address", ex.errors[0].attribute) end + + def test_multiparameter_assigned_attributes_did_not_come_from_user + topic = Topic.new( + "written_on(1i)" => "1952", + "written_on(2i)" => "3", + "written_on(3i)" => "11", + "written_on(4i)" => "13", + "written_on(5i)" => "55", + ) + assert_not_predicate topic, :written_on_came_from_user? + end end diff --git a/activerecord/test/cases/multiple_db_test.rb b/activerecord/test/cases/multiple_db_test.rb index e3bb51bd77..192d2f5251 100644 --- a/activerecord/test/cases/multiple_db_test.rb +++ b/activerecord/test/cases/multiple_db_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/entrant" require "models/bird" @@ -90,14 +92,9 @@ class MultipleDbTest < ActiveRecord::TestCase assert_equal "Ruby Developer", Entrant.find(1).name end - def test_arel_table_engines - assert_not_equal Entrant.arel_engine, Bird.arel_engine - assert_not_equal Entrant.arel_engine, Course.arel_engine - end - def test_connection - assert_equal Entrant.arel_engine.connection.object_id, Bird.arel_engine.connection.object_id - assert_not_equal Entrant.arel_engine.connection.object_id, Course.arel_engine.connection.object_id + assert_same Entrant.connection, Bird.connection + assert_not_same Entrant.connection, Course.connection end unless in_memory_db? diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb index b87419d203..bb1c1ea17d 100644 --- a/activerecord/test/cases/nested_attributes_test.rb +++ b/activerecord/test/cases/nested_attributes_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/pirate" require "models/ship" @@ -34,7 +36,7 @@ class TestNestedAttributesInGeneral < ActiveRecord::TestCase pirate.birds_with_reject_all_blank_attributes = [{ name: "", color: "", _destroy: "0" }] pirate.save! - assert pirate.birds_with_reject_all_blank.empty? + assert_empty pirate.birds_with_reject_all_blank end def test_should_not_build_a_new_record_if_reject_all_blank_returns_false @@ -42,7 +44,7 @@ class TestNestedAttributesInGeneral < ActiveRecord::TestCase pirate.birds_with_reject_all_blank_attributes = [{ name: "", color: "" }] pirate.save! - assert pirate.birds_with_reject_all_blank.empty? + assert_empty pirate.birds_with_reject_all_blank end def test_should_build_a_new_record_if_reject_all_blank_does_not_return_false @@ -81,7 +83,7 @@ class TestNestedAttributesInGeneral < ActiveRecord::TestCase def test_a_model_should_respond_to_underscore_destroy_and_return_if_it_is_marked_for_destruction ship = Ship.create!(name: "Nights Dirty Lightning") - assert !ship._destroy + assert_not ship._destroy ship.mark_for_destruction assert ship._destroy end @@ -117,7 +119,7 @@ class TestNestedAttributesInGeneral < ActiveRecord::TestCase def test_reject_if_with_a_proc_which_returns_true_always_for_has_one Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| true } - pirate = Pirate.new(catchphrase: "Stop wastin' me time") + pirate = Pirate.create(catchphrase: "Stop wastin' me time") ship = pirate.create_ship(name: "s1") pirate.update(ship_attributes: { name: "s2", id: ship.id }) assert_equal "s1", ship.reload.name @@ -150,7 +152,7 @@ class TestNestedAttributesInGeneral < ActiveRecord::TestCase man = Man.create(name: "Jon") interest = man.interests.create(topic: "the ladies") man.update(interests_attributes: { _destroy: "1", id: interest.id }) - assert man.reload.interests.empty? + assert_empty man.reload.interests end def test_reject_if_is_not_short_circuited_if_allow_destroy_is_false @@ -215,6 +217,18 @@ class TestNestedAttributesInGeneral < ActiveRecord::TestCase mean_pirate.parrot_attributes = { name: "James" } assert_equal "James", mean_pirate.parrot.name end + + def test_should_not_create_duplicates_with_create_with + Man.accepts_nested_attributes_for(:interests) + + assert_difference("Interest.count", 1) do + Man.create_with( + interests_attributes: [{ topic: "Pirate king" }] + ).find_or_create_by!( + name: "Monkey D. Luffy" + ) + end + end end class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase @@ -238,7 +252,7 @@ class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase @ship.destroy @pirate.reload.ship_attributes = { name: "Davy Jones Gold Dagger" } - assert !@pirate.ship.persisted? + assert_not_predicate @pirate.ship, :persisted? assert_equal "Davy Jones Gold Dagger", @pirate.ship.name end @@ -259,7 +273,7 @@ class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase def test_should_replace_an_existing_record_if_there_is_no_id @pirate.reload.ship_attributes = { name: "Davy Jones Gold Dagger" } - assert !@pirate.ship.persisted? + assert_not_predicate @pirate.ship, :persisted? assert_equal "Davy Jones Gold Dagger", @pirate.ship.name assert_equal "Nights Dirty Lightning", @ship.name end @@ -333,7 +347,7 @@ class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase def test_should_also_work_with_a_HashWithIndifferentAccess @pirate.ship_attributes = ActiveSupport::HashWithIndifferentAccess.new(id: @ship.id, name: "Davy Jones Gold Dagger") - assert @pirate.ship.persisted? + assert_predicate @pirate.ship, :persisted? assert_equal "Davy Jones Gold Dagger", @pirate.ship.name end @@ -348,12 +362,12 @@ class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase def test_should_not_destroy_the_associated_model_until_the_parent_is_saved @pirate.attributes = { ship_attributes: { id: @ship.id, _destroy: "1" } } - assert !@pirate.ship.destroyed? - assert @pirate.ship.marked_for_destruction? + assert_not_predicate @pirate.ship, :destroyed? + assert_predicate @pirate.ship, :marked_for_destruction? @pirate.save - assert @pirate.ship.destroyed? + assert_predicate @pirate.ship, :destroyed? assert_nil @pirate.reload.ship end @@ -422,7 +436,7 @@ class TestNestedAttributesOnABelongsToAssociation < ActiveRecord::TestCase @pirate.destroy @ship.reload.pirate_attributes = { catchphrase: "Arr" } - assert !@ship.pirate.persisted? + assert_not_predicate @ship.pirate, :persisted? assert_equal "Arr", @ship.pirate.catchphrase end @@ -443,7 +457,7 @@ class TestNestedAttributesOnABelongsToAssociation < ActiveRecord::TestCase def test_should_replace_an_existing_record_if_there_is_no_id @ship.reload.pirate_attributes = { catchphrase: "Arr" } - assert !@ship.pirate.persisted? + assert_not_predicate @ship.pirate, :persisted? assert_equal "Arr", @ship.pirate.catchphrase assert_equal "Aye", @pirate.catchphrase end @@ -548,7 +562,7 @@ class TestNestedAttributesOnABelongsToAssociation < ActiveRecord::TestCase @pirate.delete @ship.reload.attributes = { update_only_pirate_attributes: { catchphrase: "Arr" } } - assert !@ship.update_only_pirate.persisted? + assert_not_predicate @ship.update_only_pirate, :persisted? end def test_should_update_existing_when_update_only_is_true_and_no_id_is_given @@ -630,10 +644,10 @@ module NestedAttributesOnACollectionAssociationTests def test_should_not_load_association_when_updating_existing_records @pirate.reload @pirate.send(association_setter, [{ id: @child_1.id, name: "Grace OMalley" }]) - assert ! @pirate.send(@association_name).loaded? + assert_not_predicate @pirate.send(@association_name), :loaded? @pirate.save - assert ! @pirate.send(@association_name).loaded? + assert_not_predicate @pirate.send(@association_name), :loaded? assert_equal "Grace OMalley", @child_1.reload.name end @@ -661,13 +675,12 @@ module NestedAttributesOnACollectionAssociationTests def test_should_not_remove_scheduled_destroys_when_loading_association @pirate.reload @pirate.send(association_setter, [{ id: @child_1.id, _destroy: "1" }]) - assert @pirate.send(@association_name).load_target.find { |r| r.id == @child_1.id }.marked_for_destruction? + assert_predicate @pirate.send(@association_name).load_target.find { |r| r.id == @child_1.id }, :marked_for_destruction? end def test_should_take_a_hash_with_composite_id_keys_and_assign_the_attributes_to_the_associated_models @child_1.stub(:id, "ABC1X") do @child_2.stub(:id, "ABC2X") do - @pirate.attributes = { association_getter => [ { id: @child_1.id, name: "Grace OMalley" }, @@ -703,10 +716,10 @@ module NestedAttributesOnACollectionAssociationTests association_getter => { "foo" => { name: "Grace OMalley" }, "bar" => { name: "Privateers Greed" } } } - assert !@pirate.send(@association_name).first.persisted? + assert_not_predicate @pirate.send(@association_name).first, :persisted? assert_equal "Grace OMalley", @pirate.send(@association_name).first.name - assert !@pirate.send(@association_name).last.persisted? + assert_not_predicate @pirate.send(@association_name).last, :persisted? assert_equal "Privateers Greed", @pirate.send(@association_name).last.name end @@ -752,7 +765,7 @@ module NestedAttributesOnACollectionAssociationTests exception = assert_raise ArgumentError do @pirate.send(association_setter, "foo") end - assert_equal 'Hash or Array expected, got String ("foo")', exception.message + assert_equal %{Hash or Array expected for attribute `#{@association_name}`, got String ("foo")}, exception.message end def test_should_work_with_update_as_well @@ -833,7 +846,7 @@ module NestedAttributesOnACollectionAssociationTests man = Man.create(name: "John") interest = man.interests.create(topic: "bar", zine_id: 0) assert interest.save - assert !man.update(interests_attributes: { id: interest.id, zine_id: "foo" }) + assert_not man.update(interests_attributes: { id: interest.id, zine_id: "foo" }) end end @@ -1089,7 +1102,19 @@ class TestHasManyAutosaveAssociationWhichItselfHasAutosaveAssociations < ActiveR test "nested singular associations are validated" do part = ShipPart.new(name: "Stern", ship_attributes: { name: nil }) - assert_not part.valid? + assert_not_predicate part, :valid? assert_equal ["Ship name can't be blank"], part.errors.full_messages end end + +class TestNestedAttributesWithExtend < ActiveRecord::TestCase + setup do + Pirate.accepts_nested_attributes_for :treasures + end + + def test_extend_affects_nested_attributes + pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?") + pirate.treasures_attributes = [{ id: nil }] + assert_equal "from extension", pirate.treasures[0].name + end +end diff --git a/activerecord/test/cases/nested_attributes_with_callbacks_test.rb b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb index b9d2acbed2..1d26057fdc 100644 --- a/activerecord/test/cases/nested_attributes_with_callbacks_test.rb +++ b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/pirate" require "models/bird" @@ -61,7 +63,7 @@ class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase # Characterizing when :before_add callback is called test ":before_add called for new bird when not loaded" do - assert_not @pirate.birds_with_add.loaded? + assert_not_predicate @pirate.birds_with_add, :loaded? @pirate.birds_with_add_attributes = new_bird_attributes assert_new_bird_with_callback_called end @@ -78,7 +80,7 @@ class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase end test ":before_add not called for identical assignment when not loaded" do - assert_not @pirate.birds_with_add.loaded? + assert_not_predicate @pirate.birds_with_add, :loaded? @pirate.birds_with_add_attributes = existing_birds_attributes assert_callbacks_not_called end @@ -90,7 +92,7 @@ class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase end test ":before_add not called for destroy assignment when not loaded" do - assert_not @pirate.birds_with_add.loaded? + assert_not_predicate @pirate.birds_with_add, :loaded? @pirate.birds_with_add_attributes = destroy_bird_attributes assert_callbacks_not_called end @@ -109,7 +111,7 @@ class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase # Ensuring that the records in the association target are updated, # whether the association is loaded before or not test "Assignment updates records in target when not loaded" do - assert_not @pirate.birds_with_add.loaded? + assert_not_predicate @pirate.birds_with_add, :loaded? @pirate.birds_with_add_attributes = update_new_and_destroy_bird_attributes assert_assignment_affects_records_in_target(:birds_with_add) end @@ -122,7 +124,7 @@ class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase test("Assignment updates records in target when not loaded" \ " and callback loads target") do - assert_not @pirate.birds_with_add_load.loaded? + assert_not_predicate @pirate.birds_with_add_load, :loaded? @pirate.birds_with_add_load_attributes = update_new_and_destroy_bird_attributes assert_assignment_affects_records_in_target(:birds_with_add_load) end diff --git a/activerecord/test/cases/null_relation_test.rb b/activerecord/test/cases/null_relation_test.rb new file mode 100644 index 0000000000..17527568f8 --- /dev/null +++ b/activerecord/test/cases/null_relation_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/comment" +require "models/post" +require "models/topic" + +class NullRelationTest < ActiveRecord::TestCase + fixtures :posts, :comments + + def test_none + assert_no_queries(ignore_none: false) do + assert_equal [], Developer.none + assert_equal [], Developer.all.none + end + end + + def test_none_chainable + assert_no_queries(ignore_none: false) do + assert_equal [], Developer.none.where(name: "David") + end + end + + def test_none_chainable_to_existing_scope_extension_method + assert_no_queries(ignore_none: false) do + assert_equal 1, Topic.anonymous_extension.none.one + end + end + + def test_none_chained_to_methods_firing_queries_straight_to_db + assert_no_queries(ignore_none: false) do + assert_equal [], Developer.none.pluck(:id, :name) + assert_equal 0, Developer.none.delete_all + assert_equal 0, Developer.none.update_all(name: "David") + assert_equal 0, Developer.none.delete(1) + assert_equal false, Developer.none.exists?(1) + end + end + + def test_null_relation_content_size_methods + assert_no_queries(ignore_none: false) do + assert_equal 0, Developer.none.size + assert_equal 0, Developer.none.count + assert_equal true, Developer.none.empty? + assert_equal true, Developer.none.none? + assert_equal false, Developer.none.any? + assert_equal false, Developer.none.one? + assert_equal false, Developer.none.many? + end + end + + def test_null_relation_metadata_methods + assert_equal "", Developer.none.to_sql + assert_equal({}, Developer.none.where_values_hash) + end + + def test_null_relation_where_values_hash + assert_equal({ "salary" => 100_000 }, Developer.none.where(salary: 100_000).where_values_hash) + end + + [:count, :sum].each do |method| + define_method "test_null_relation_#{method}" do + assert_no_queries(ignore_none: false) do + assert_equal 0, Comment.none.public_send(method, :id) + assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id) + end + end + end + + [:average, :minimum, :maximum].each do |method| + define_method "test_null_relation_#{method}" do + assert_no_queries(ignore_none: false) do + assert_nil Comment.none.public_send(method, :id) + assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id) + end + end + end + + def test_null_relation_in_where_condition + assert_operator Comment.count, :>, 0 # precondition, make sure there are comments. + assert_equal 0, Comment.where(post_id: Post.none).count + end +end diff --git a/activerecord/test/cases/numeric_data_test.rb b/activerecord/test/cases/numeric_data_test.rb new file mode 100644 index 0000000000..14db63890e --- /dev/null +++ b/activerecord/test/cases/numeric_data_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/numeric_data" + +class NumericDataTest < ActiveRecord::TestCase + def test_big_decimal_conditions + m = NumericData.new( + bank_balance: 1586.43, + big_bank_balance: BigDecimal("1000234000567.95"), + world_population: 6000000000, + my_house_population: 3 + ) + assert m.save + assert_equal 0, NumericData.where("bank_balance > ?", 2000.0).count + end + + def test_numeric_fields + m = NumericData.new( + bank_balance: 1586.43, + big_bank_balance: BigDecimal("1000234000567.95"), + world_population: 2**62, + my_house_population: 3 + ) + assert m.save + + m1 = NumericData.find(m.id) + assert_not_nil m1 + + assert_kind_of Integer, m1.world_population + assert_equal 2**62, m1.world_population + + assert_kind_of Integer, m1.my_house_population + assert_equal 3, m1.my_house_population + + assert_kind_of BigDecimal, m1.bank_balance + assert_equal BigDecimal("1586.43"), m1.bank_balance + + assert_kind_of BigDecimal, m1.big_bank_balance + assert_equal BigDecimal("1000234000567.95"), m1.big_bank_balance + end + + def test_numeric_fields_with_scale + m = NumericData.new( + bank_balance: 1586.43122334, + big_bank_balance: BigDecimal("234000567.952344"), + world_population: 2**62, + my_house_population: 3 + ) + assert m.save + + m1 = NumericData.find(m.id) + assert_not_nil m1 + + assert_kind_of Integer, m1.world_population + assert_equal 2**62, m1.world_population + + assert_kind_of Integer, m1.my_house_population + assert_equal 3, m1.my_house_population + + assert_kind_of BigDecimal, m1.bank_balance + assert_equal BigDecimal("1586.43"), m1.bank_balance + + assert_kind_of BigDecimal, m1.big_bank_balance + assert_equal BigDecimal("234000567.95"), m1.big_bank_balance + end +end diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb index 5b7e2fd008..7348a22dd3 100644 --- a/activerecord/test/cases/persistence_test.rb +++ b/activerecord/test/cases/persistence_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/aircraft" require "models/post" @@ -46,10 +48,10 @@ class PersistenceTest < ActiveRecord::TestCase end if test_update_with_order_succeeds.call("id DESC") - assert !test_update_with_order_succeeds.call("id ASC") # test that this wasn't a fluke and using an incorrect order results in an exception + assert_not test_update_with_order_succeeds.call("id ASC") # test that this wasn't a fluke and using an incorrect order results in an exception else # test that we're failing because the current Arel's engine doesn't support UPDATE ORDER BY queries is using subselects instead - assert_sql(/\AUPDATE .+ \(SELECT .* ORDER BY id DESC\)\Z/i) do + assert_sql(/\AUPDATE .+ \(SELECT .* ORDER BY id DESC\)\z/i) do test_update_with_order_succeeds.call("id DESC") end end @@ -57,13 +59,42 @@ class PersistenceTest < ActiveRecord::TestCase def test_update_all_with_order_and_limit_updates_subset_only author = authors(:david) - assert_nothing_raised do - assert_equal 1, author.posts_sorted_by_id_limited.size - assert_equal 2, author.posts_sorted_by_id_limited.limit(2).to_a.size - assert_equal 1, author.posts_sorted_by_id_limited.update_all([ "body = ?", "bulk update!" ]) - assert_equal "bulk update!", posts(:welcome).body - assert_not_equal "bulk update!", posts(:thinking).body - end + limited_posts = author.posts_sorted_by_id_limited + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.update_all([ "body = ?", "bulk update!" ]) + assert_equal "bulk update!", posts(:welcome).body + assert_not_equal "bulk update!", posts(:thinking).body + end + + def test_update_all_with_order_and_limit_and_offset_updates_subset_only + author = authors(:david) + limited_posts = author.posts_sorted_by_id_limited.offset(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.update_all([ "body = ?", "bulk update!" ]) + assert_equal "bulk update!", posts(:thinking).body + assert_not_equal "bulk update!", posts(:welcome).body + end + + def test_delete_all_with_order_and_limit_deletes_subset_only + author = authors(:david) + limited_posts = Post.where(author: author).order(:id).limit(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { posts(:welcome) } + assert posts(:thinking) + end + + def test_delete_all_with_order_and_limit_and_offset_deletes_subset_only + author = authors(:david) + limited_posts = Post.where(author: author).order(:id).limit(1).offset(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { posts(:thinking) } + assert posts(:welcome) end end @@ -71,11 +102,43 @@ class PersistenceTest < ActiveRecord::TestCase topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } updated = Topic.update(topic_data.keys, topic_data.values) - assert_equal 2, updated.size + assert_equal [1, 2], updated.map(&:id) assert_equal "1 updated", Topic.find(1).content assert_equal "2 updated", Topic.find(2).content end + def test_update_many_with_duplicated_ids + updated = Topic.update([1, 1, 2], [ + { "content" => "1 duplicated" }, { "content" => "1 updated" }, { "content" => "2 updated" } + ]) + + assert_equal [1, 1, 2], updated.map(&:id) + assert_equal "1 updated", Topic.find(1).content + assert_equal "2 updated", Topic.find(2).content + end + + def test_update_many_with_invalid_id + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" }, 99999 => {} } + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.update(topic_data.keys, topic_data.values) + end + + assert_not_equal "1 updated", Topic.find(1).content + assert_not_equal "2 updated", Topic.find(2).content + end + + def test_class_level_update_is_affected_by_scoping + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.where("1=0").scoping { Topic.update(topic_data.keys, topic_data.values) } + end + + assert_not_equal "1 updated", Topic.find(1).content + assert_not_equal "2 updated", Topic.find(2).content + end + def test_delete_all assert Topic.count > 0 @@ -83,27 +146,31 @@ class PersistenceTest < ActiveRecord::TestCase end def test_delete_all_with_joins_and_where_part_is_hash - where_args = { toys: { name: "Bone" } } - count = Pet.joins(:toys).where(where_args).count + pets = Pet.joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + def test_delete_all_with_joins_and_where_part_is_not_hash + pets = Pet.joins(:toys).where("toys.name = ?", "Bone") - assert_equal count, 1 - assert_equal count, Pet.joins(:toys).where(where_args).delete_all + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all end def test_delete_all_with_left_joins - where_args = { toys: { name: "Bone" } } - count = Pet.left_joins(:toys).where(where_args).count + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) - assert_equal count, 1 - assert_equal count, Pet.left_joins(:toys).where(where_args).delete_all + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all end - def test_delete_all_with_joins_and_where_part_is_not_hash - where_args = ["toys.name = ?", "Bone"] - count = Pet.joins(:toys).where(where_args).count + def test_delete_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) - assert_equal count, 1 - assert_equal count, Pet.joins(:toys).where(where_args).delete_all + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all end def test_increment_attribute @@ -139,18 +206,34 @@ class PersistenceTest < ActiveRecord::TestCase assert_equal initial_credit + 2, a1.reload.credit_limit end - def test_increment_updates_timestamps + def test_increment_with_touch_updates_timestamps + topic = topics(:first) + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + travel(1.second) do + topic.increment!(:replies_count, touch: true) + end + assert_equal 2, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + end + + def test_increment_with_touch_an_attribute_updates_timestamps topic = topics(:first) - topic.update_columns(updated_at: 5.minutes.ago) - previous_updated_at = topic.updated_at - topic.increment!(:replies_count, touch: true) - assert_operator previous_updated_at, :<, topic.reload.updated_at + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + previously_written_on = topic.written_on + travel(1.second) do + topic.increment!(:replies_count, touch: :written_on) + end + assert_equal 2, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + assert_operator previously_written_on, :<, topic.written_on end def test_destroy_all conditions = "author_name = 'Mary'" topics_by_mary = Topic.all.merge!(where: conditions, order: "id").to_a - assert ! topics_by_mary.empty? + assert_not_empty topics_by_mary assert_difference("Topic.count", -topics_by_mary.size) do destroyed = Topic.where(conditions).destroy_all.sort_by(&:id) @@ -160,23 +243,40 @@ class PersistenceTest < ActiveRecord::TestCase end def test_destroy_many - clients = Client.all.merge!(order: "id").find([2, 3]) + clients = Client.find([2, 3]) assert_difference("Client.count", -2) do - destroyed = Client.destroy([2, 3]).sort_by(&:id) + destroyed = Client.destroy([2, 3]) assert_equal clients, destroyed assert destroyed.all?(&:frozen?), "destroyed clients should be frozen" end end + def test_destroy_many_with_invalid_id + clients = Client.find([2, 3]) + + assert_raise(ActiveRecord::RecordNotFound) do + Client.destroy([2, 3, 99999]) + end + + assert_equal clients, Client.find([2, 3]) + end + def test_becomes assert_kind_of Reply, topics(:first).becomes(Reply) assert_equal "The First Topic", topics(:first).becomes(Reply).title end + def test_becomes_after_reload_schema_from_cache + Reply.define_attribute_methods + Reply.serialize(:content) # invoke reload_schema_from_cache + assert_kind_of Reply, topics(:first).becomes(Reply) + assert_equal "The First Topic", topics(:first).becomes(Reply).title + end + def test_becomes_includes_errors company = Company.new(name: nil) - assert !company.valid? + assert_not_predicate company, :valid? original_errors = company.errors client = company.becomes(Client) assert_equal original_errors.keys, client.errors.keys @@ -206,6 +306,17 @@ class PersistenceTest < ActiveRecord::TestCase assert_equal "The First Topic", Topic.find(copy.id).title end + def test_becomes_wont_break_mutation_tracking + topic = topics(:first) + reply = topic.becomes(Reply) + + assert_equal 1, topic.id_in_database + assert_empty topic.attributes_in_database + + assert_equal 1, reply.id_in_database + assert_empty reply.attributes_in_database + end + def test_becomes_includes_changed_attributes company = Company.new(name: "37signals") client = company.becomes(Client) @@ -238,12 +349,28 @@ class PersistenceTest < ActiveRecord::TestCase assert_equal 41, accounts(:signals37, :reload).credit_limit end - def test_decrement_updates_timestamps + def test_decrement_with_touch_updates_timestamps topic = topics(:first) - topic.update_columns(updated_at: 5.minutes.ago) - previous_updated_at = topic.updated_at - topic.decrement!(:replies_count, touch: true) - assert_operator previous_updated_at, :<, topic.reload.updated_at + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + travel(1.second) do + topic.decrement!(:replies_count, touch: true) + end + assert_equal 0, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + end + + def test_decrement_with_touch_an_attribute_updates_timestamps + topic = topics(:first) + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + previously_written_on = topic.written_on + travel(1.second) do + topic.decrement!(:replies_count, touch: :written_on) + end + assert_equal 0, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + assert_operator previously_written_on, :<, topic.written_on end def test_create @@ -293,8 +420,8 @@ class PersistenceTest < ActiveRecord::TestCase developer.destroy new_developer = developer.dup new_developer.save - assert new_developer.persisted? - assert_not new_developer.destroyed? + assert_predicate new_developer, :persisted? + assert_not_predicate new_developer, :destroyed? end def test_create_many @@ -305,12 +432,13 @@ class PersistenceTest < ActiveRecord::TestCase def test_create_columns_not_equal_attributes topic = Topic.instantiate( - "attributes" => { - "title" => "Another New Topic", - "does_not_exist" => "test" - } + "title" => "Another New Topic", + "does_not_exist" => "test" ) + topic = topic.dup # reset @new_record assert_nothing_raised { topic.save } + assert_predicate topic, :persisted? + assert_equal "Another New Topic", topic.reload.title end def test_create_through_factory_with_block @@ -357,6 +485,8 @@ class PersistenceTest < ActiveRecord::TestCase topic_reloaded = Topic.instantiate(topic.attributes.merge("does_not_exist" => "test")) topic_reloaded.title = "A New Topic" assert_nothing_raised { topic_reloaded.save } + assert_predicate topic_reloaded, :persisted? + assert_equal "A New Topic", topic_reloaded.reload.title end def test_update_for_record_with_only_primary_key @@ -393,6 +523,22 @@ class PersistenceTest < ActiveRecord::TestCase assert_instance_of Reply, Reply.find(reply.id) end + def test_becomes_default_sti_subclass + original_type = Topic.columns_hash["type"].default + ActiveRecord::Base.connection.change_column_default :topics, :type, "Reply" + Topic.reset_column_information + + reply = topics(:second) + assert_instance_of Reply, reply + + topic = reply.becomes(Topic) + assert_instance_of Topic, topic + + ensure + ActiveRecord::Base.connection.change_column_default :topics, :type, original_type + Topic.reset_column_information + end + def test_update_after_create klass = Class.new(Topic) do def self.name; "Topic"; end @@ -422,7 +568,7 @@ class PersistenceTest < ActiveRecord::TestCase def test_update_does_not_run_sql_if_record_has_not_changed topic = Topic.create(title: "Another New Topic") assert_queries(0) { assert topic.update(title: "Another New Topic") } - assert_queries(0) { assert topic.update_attributes(title: "Another New Topic") } + assert_queries(0) { assert topic.update(title: "Another New Topic") } end def test_delete @@ -437,6 +583,13 @@ class PersistenceTest < ActiveRecord::TestCase assert_not_nil Topic.find(2) end + def test_delete_isnt_affected_by_scoping + topic = Topic.find(1) + assert_difference("Topic.count", -1) do + Topic.where("1=0").scoping { topic.delete } + end + end + def test_destroy topic = Topic.find(1) assert_equal topic, topic.destroy, "topic.destroy did not return self" @@ -451,10 +604,18 @@ class PersistenceTest < ActiveRecord::TestCase assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) } end - def test_record_not_found_exception + def test_find_raises_record_not_found_exception assert_raise(ActiveRecord::RecordNotFound) { Topic.find(99999) } end + def test_update_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.update(99999, approved: true) } + end + + def test_destroy_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.destroy(99999) } + end + def test_update_all assert_equal Topic.count, Topic.update_all("content = 'bulk updated!'") assert_equal "bulk updated!", Topic.find(1).content @@ -478,17 +639,24 @@ class PersistenceTest < ActiveRecord::TestCase end def test_update_all_with_joins - where_args = { toys: { name: "Bone" } } - count = Pet.left_joins(:toys).where(where_args).count + pets = Pet.joins(:toys).where(toys: { name: "Bone" }) - assert_equal count, Pet.joins(:toys).where(where_args).update_all(name: "Bob") + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") end def test_update_all_with_left_joins - where_args = { toys: { name: "Bone" } } - count = Pet.left_joins(:toys).where(where_args).count + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") + end + + def test_update_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) - assert_equal count, Pet.left_joins(:toys).where(where_args).update_all(name: "Bob") + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") end def test_update_all_with_non_standard_table_name @@ -497,40 +665,65 @@ class PersistenceTest < ActiveRecord::TestCase end def test_delete_new_record - client = Client.new + client = Client.new(name: "37signals") client.delete - assert client.frozen? + assert_predicate client, :frozen? + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? + assert_raise(RuntimeError) { client.name = "something else" } end def test_delete_record_with_associations client = Client.find(3) client.delete - assert client.frozen? + assert_predicate client, :frozen? assert_kind_of Firm, client.firm + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? assert_raise(RuntimeError) { client.name = "something else" } end def test_destroy_new_record - client = Client.new + client = Client.new(name: "37signals") client.destroy - assert client.frozen? + assert_predicate client, :frozen? + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? + assert_raise(RuntimeError) { client.name = "something else" } end def test_destroy_record_with_associations client = Client.find(3) client.destroy - assert client.frozen? + assert_predicate client, :frozen? assert_kind_of Firm, client.firm + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? assert_raise(RuntimeError) { client.name = "something else" } end def test_update_attribute - assert !Topic.find(1).approved? + assert_not_predicate Topic.find(1), :approved? Topic.find(1).update_attribute("approved", true) - assert Topic.find(1).approved? + assert_predicate Topic.find(1), :approved? Topic.find(1).update_attribute(:approved, false) - assert !Topic.find(1).approved? + assert_not_predicate Topic.find(1), :approved? + + Topic.find(1).update_attribute(:change_approved_before_save, true) + assert_predicate Topic.find(1), :approved? end def test_update_attribute_for_readonly_attribute @@ -542,8 +735,8 @@ class PersistenceTest < ActiveRecord::TestCase t = Topic.first t.update_attribute(:title, "super_title") assert_equal "super_title", t.title - assert !t.changed?, "topic should not have changed" - assert !t.title_changed?, "title should not have changed" + assert_not t.changed?, "topic should not have changed" + assert_not t.title_changed?, "title should not have changed" assert_nil t.title_change, "title change should be nil" t.reload @@ -567,14 +760,14 @@ class PersistenceTest < ActiveRecord::TestCase def test_update_column topic = Topic.find(1) topic.update_column("approved", true) - assert topic.approved? + assert_predicate topic, :approved? topic.reload - assert topic.approved? + assert_predicate topic, :approved? topic.update_column(:approved, false) - assert !topic.approved? + assert_not_predicate topic, :approved? topic.reload - assert !topic.approved? + assert_not_predicate topic, :approved? end def test_update_column_should_not_use_setter_method @@ -661,10 +854,10 @@ class PersistenceTest < ActiveRecord::TestCase def test_update_columns topic = Topic.find(1) topic.update_columns("approved" => true, title: "Sebastian Topic") - assert topic.approved? + assert_predicate topic, :approved? assert_equal "Sebastian Topic", topic.title topic.reload - assert topic.approved? + assert_predicate topic, :approved? assert_equal "Sebastian Topic", topic.title end @@ -772,54 +965,45 @@ class PersistenceTest < ActiveRecord::TestCase def test_update topic = Topic.find(1) - assert !topic.approved? + assert_not_predicate topic, :approved? assert_equal "The First Topic", topic.title topic.update("approved" => true, "title" => "The First Topic Updated") topic.reload - assert topic.approved? + assert_predicate topic, :approved? assert_equal "The First Topic Updated", topic.title topic.update(approved: false, title: "The First Topic") topic.reload - assert !topic.approved? - assert_equal "The First Topic", topic.title - end - - def test_update_attributes - topic = Topic.find(1) - assert !topic.approved? - assert_equal "The First Topic", topic.title - - topic.update_attributes("approved" => true, "title" => "The First Topic Updated") - topic.reload - assert topic.approved? - assert_equal "The First Topic Updated", topic.title - - topic.update_attributes(approved: false, title: "The First Topic") - topic.reload - assert !topic.approved? + assert_not_predicate topic, :approved? assert_equal "The First Topic", topic.title error = assert_raise(ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid) do - topic.update_attributes(id: 3, title: "Hm is it possible?") + topic.update(id: 3, title: "Hm is it possible?") end assert_not_nil error.cause assert_not_equal "Hm is it possible?", Topic.find(3).title - topic.update_attributes(id: 1234) + topic.update(id: 1234) assert_nothing_raised { topic.reload } assert_equal topic.title, Topic.find(1234).title end - def test_update_attributes_parameters + def test_update_attributes + topic = Topic.find(1) + assert_deprecated do + topic.update_attributes("title" => "The First Topic Updated") + end + end + + def test_update_parameters topic = Topic.find(1) assert_nothing_raised do - topic.update_attributes({}) + topic.update({}) end assert_raises(ArgumentError) do - topic.update_attributes(nil) + topic.update(nil) end end @@ -845,24 +1029,10 @@ class PersistenceTest < ActiveRecord::TestCase end def test_update_attributes! - Reply.validates_presence_of(:title) reply = Reply.find(2) - assert_equal "The Second Topic of the day", reply.title - assert_equal "Have a nice day", reply.content - - reply.update_attributes!("title" => "The Second Topic of the day updated", "content" => "Have a nice evening") - reply.reload - assert_equal "The Second Topic of the day updated", reply.title - assert_equal "Have a nice evening", reply.content - - reply.update_attributes!(title: "The Second Topic of the day", content: "Have a nice day") - reply.reload - assert_equal "The Second Topic of the day", reply.title - assert_equal "Have a nice day", reply.content - - assert_raise(ActiveRecord::RecordInvalid) { reply.update_attributes!(title: nil, content: "Have a nice evening") } - ensure - Reply.clear_validators! + assert_deprecated do + reply.update_attributes!("title" => "The Second Topic of the day updated") + end end def test_destroyed_returns_boolean @@ -898,18 +1068,41 @@ class PersistenceTest < ActiveRecord::TestCase should_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") Topic.find(1).replies << should_be_destroyed_reply - Topic.destroy(1) + topic = Topic.destroy(1) + assert_predicate topic, :destroyed? + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) } assert_raise(ActiveRecord::RecordNotFound) { Reply.find(should_be_destroyed_reply.id) } end + def test_class_level_destroy_is_affected_by_scoping + should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_not_be_destroyed_reply + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.where("1=0").scoping { Topic.destroy(1) } + end + + assert_nothing_raised { Topic.find(1) } + assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) } + end + def test_class_level_delete - should_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") - Topic.find(1).replies << should_be_destroyed_reply + should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_not_be_destroyed_reply Topic.delete(1) assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) } - assert_nothing_raised { Reply.find(should_be_destroyed_reply.id) } + assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) } + end + + def test_class_level_delete_is_affected_by_scoping + should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_not_be_destroyed_reply + + Topic.where("1=0").scoping { Topic.delete(1) } + assert_nothing_raised { Topic.find(1) } + assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) } end def test_create_with_custom_timestamps @@ -956,13 +1149,13 @@ class PersistenceTest < ActiveRecord::TestCase def test_find_via_reload post = Post.new - assert post.new_record? + assert_predicate post, :new_record? post.id = 1 post.reload assert_equal "Welcome to the weblog", post.title - assert_not post.new_record? + assert_not_predicate post, :new_record? end def test_reload_via_querycache @@ -993,44 +1186,35 @@ class PersistenceTest < ActiveRecord::TestCase end class SaveTest < ActiveRecord::TestCase - self.use_transactional_tests = false - def test_save_touch_false - widget = Class.new(ActiveRecord::Base) do - connection.create_table :widgets, force: true do |t| - t.string :name - t.timestamps null: false - end - - self.table_name = :widgets - end - - instance = widget.create!( + pet = Pet.create!( name: "Bob", created_at: 1.day.ago, updated_at: 1.day.ago) - created_at = instance.created_at - updated_at = instance.updated_at + created_at = pet.created_at + updated_at = pet.updated_at - instance.name = "Barb" - instance.save!(touch: false) - assert_equal instance.created_at, created_at - assert_equal instance.updated_at, updated_at - ensure - ActiveRecord::Base.connection.drop_table widget.table_name - widget.reset_column_information + pet.name = "Barb" + pet.save!(touch: false) + assert_equal pet.created_at, created_at + assert_equal pet.updated_at, updated_at end end def test_reset_column_information_resets_children - child = Class.new(Topic) - child.new # force schema to load + child_class = Class.new(Topic) + child_class.new # force schema to load ActiveRecord::Base.connection.add_column(:topics, :foo, :string) Topic.reset_column_information - assert_equal "bar", child.new(foo: :bar).foo + # this should redefine attribute methods + child_class.new + + assert child_class.instance_methods.include?(:foo) + assert child_class.instance_methods.include?(:foo_changed?) + assert_equal "bar", child_class.new(foo: :bar).foo ensure ActiveRecord::Base.connection.remove_column(:topics, :foo) Topic.reset_column_information diff --git a/activerecord/test/cases/pooled_connections_test.rb b/activerecord/test/cases/pooled_connections_test.rb index f1b0d08765..fa7f759e51 100644 --- a/activerecord/test/cases/pooled_connections_test.rb +++ b/activerecord/test/cases/pooled_connections_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/project" require "timeout" diff --git a/activerecord/test/cases/primary_keys_test.rb b/activerecord/test/cases/primary_keys_test.rb index 12386635f6..4ed7469039 100644 --- a/activerecord/test/cases/primary_keys_test.rb +++ b/activerecord/test/cases/primary_keys_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" require "models/topic" @@ -46,7 +48,7 @@ class PrimaryKeysTest < ActiveRecord::TestCase topic = Topic.new topic.title = "New Topic" assert_nil topic.id - assert_nothing_raised { topic.save! } + topic.save! id = topic.id topicReloaded = Topic.find(id) @@ -56,23 +58,36 @@ class PrimaryKeysTest < ActiveRecord::TestCase def test_customized_primary_key_auto_assigns_on_save Keyboard.delete_all keyboard = Keyboard.new(name: "HHKB") - assert_nothing_raised { keyboard.save! } + keyboard.save! assert_equal keyboard.id, Keyboard.find_by_name("HHKB").id end def test_customized_primary_key_can_be_get_before_saving keyboard = Keyboard.new assert_nil keyboard.id - assert_nothing_raised { assert_nil keyboard.key_number } + assert_nil keyboard.key_number end def test_customized_string_primary_key_settable_before_save subscriber = Subscriber.new - assert_nothing_raised { subscriber.id = "webster123" } + subscriber.id = "webster123" assert_equal "webster123", subscriber.id assert_equal "webster123", subscriber.nick end + def test_update_with_non_primary_key_id_column + subscriber = Subscriber.first + subscriber.update(update_count: 1) + subscriber.reload + assert_equal 1, subscriber.update_count + end + + def test_update_columns_with_non_primary_key_id_column + subscriber = Subscriber.first + subscriber.update_columns(id: 1) + assert_not_equal 1, subscriber.nick + end + def test_string_key subscriber = Subscriber.find(subscribers(:first).nick) assert_equal(subscribers(:first).name, subscriber.name) @@ -83,7 +98,7 @@ class PrimaryKeysTest < ActiveRecord::TestCase subscriber.id = "jdoe" assert_equal("jdoe", subscriber.id) subscriber.name = "John Doe" - assert_nothing_raised { subscriber.save! } + subscriber.save! assert_equal("jdoe", subscriber.id) subscriberReloaded = Subscriber.find("jdoe") @@ -141,10 +156,6 @@ class PrimaryKeysTest < ActiveRecord::TestCase assert_nothing_raised { MixedCaseMonkey.find(1).destroy } end - def test_deprecate_supports_primary_key - assert_deprecated { ActiveRecord::Base.connection.supports_primary_key? } - end - def test_primary_key_returns_value_if_it_exists klass = Class.new(ActiveRecord::Base) do self.table_name = "developers" @@ -183,6 +194,8 @@ class PrimaryKeysTest < ActiveRecord::TestCase end def test_create_without_primary_key_no_extra_query + skip if current_adapter?(:OracleAdapter) + klass = Class.new(ActiveRecord::Base) do self.table_name = "dashboards" end @@ -194,13 +207,13 @@ class PrimaryKeysTest < ActiveRecord::TestCase def test_serial_with_quoted_sequence_name column = MixedCaseMonkey.columns_hash[MixedCaseMonkey.primary_key] assert_equal "nextval('\"mixed_case_monkeys_monkeyID_seq\"'::regclass)", column.default_function - assert column.serial? + assert_predicate column, :serial? end def test_serial_with_unquoted_sequence_name column = Topic.columns_hash[Topic.primary_key] assert_equal "nextval('topics_id_seq'::regclass)", column.default_function - assert column.serial? + assert_predicate column, :serial? end end end @@ -285,11 +298,14 @@ class PrimaryKeyAnyTypeTest < ActiveRecord::TestCase assert_not column.null assert_equal :string, column.type assert_equal 42, column.limit + ensure + Barcode.reset_column_information end test "schema dump primary key includes type and options" do schema = dump_table_schema "barcodes" assert_match %r{create_table "barcodes", primary_key: "code", id: :string, limit: 42}, schema + assert_no_match %r{t\.index \["code"\]}, schema end if current_adapter?(:Mysql2Adapter) && subsecond_precision_supported? @@ -309,7 +325,7 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase def setup @connection = ActiveRecord::Base.connection @connection.schema_cache.clear! - @connection.create_table(:barcodes, primary_key: ["region", "code"], force: true) do |t| + @connection.create_table(:uber_barcodes, primary_key: ["region", "code"], force: true) do |t| t.string :region t.integer :code end @@ -317,14 +333,24 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase t.string :region t.integer :code end + @connection.create_table(:travels, primary_key: ["from", "to"], force: true) do |t| + t.string :from + t.string :to + end end def teardown - @connection.drop_table(:barcodes, if_exists: true) + @connection.drop_table :uber_barcodes, if_exists: true + @connection.drop_table :barcodes_reverse, if_exists: true + @connection.drop_table :travels, if_exists: true end def test_composite_primary_key - assert_equal ["region", "code"], @connection.primary_keys("barcodes") + assert_equal ["region", "code"], @connection.primary_keys("uber_barcodes") + end + + def test_composite_primary_key_with_reserved_words + assert_equal ["from", "to"], @connection.primary_keys("travels") end def test_composite_primary_key_out_of_order @@ -335,7 +361,7 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase def test_primary_key_issues_warning model = Class.new(ActiveRecord::Base) do def self.table_name - "barcodes" + "uber_barcodes" end end warning = capture(:stderr) do @@ -344,9 +370,9 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase assert_match(/WARNING: Active Record does not support composite primary key\./, warning) end - def test_dumping_composite_primary_key - schema = dump_table_schema "barcodes" - assert_match %r{create_table "barcodes", primary_key: \["region", "code"\]}, schema + def test_collectly_dump_composite_primary_key + schema = dump_table_schema "uber_barcodes" + assert_match %r{create_table "uber_barcodes", primary_key: \["region", "code"\]}, schema end def test_dumping_composite_primary_key_out_of_order @@ -405,7 +431,7 @@ if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter) @connection.create_table(:widgets, id: @pk_type, force: true) column = @connection.columns(:widgets).find { |c| c.name == "id" } assert_equal :integer, column.type - assert_not column.bigint? + assert_not_predicate column, :bigint? end test "primary key with serial/integer are automatically numbered" do @@ -417,32 +443,32 @@ if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter) test "schema dump primary key with serial/integer" do @connection.create_table(:widgets, id: @pk_type, force: true) schema = dump_table_schema "widgets" - assert_match %r{create_table "widgets", id: :#{@pk_type}, force: :cascade}, schema + assert_match %r{create_table "widgets", id: :#{@pk_type}, }, schema end if current_adapter?(:Mysql2Adapter) test "primary key column type with options" do @connection.create_table(:widgets, id: :primary_key, limit: 4, unsigned: true, force: true) column = @connection.columns(:widgets).find { |c| c.name == "id" } - assert column.auto_increment? + assert_predicate column, :auto_increment? assert_equal :integer, column.type - assert_not column.bigint? - assert column.unsigned? + assert_not_predicate column, :bigint? + assert_predicate column, :unsigned? schema = dump_table_schema "widgets" - assert_match %r{create_table "widgets", id: :integer, unsigned: true, force: :cascade}, schema + assert_match %r{create_table "widgets", id: :integer, unsigned: true, }, schema end test "bigint primary key with unsigned" do @connection.create_table(:widgets, id: :bigint, unsigned: true, force: true) column = @connection.columns(:widgets).find { |c| c.name == "id" } - assert column.auto_increment? + assert_predicate column, :auto_increment? assert_equal :integer, column.type - assert column.bigint? - assert column.unsigned? + assert_predicate column, :bigint? + assert_predicate column, :unsigned? schema = dump_table_schema "widgets" - assert_match %r{create_table "widgets", id: :bigint, unsigned: true, force: :cascade}, schema + assert_match %r{create_table "widgets", id: :bigint, unsigned: true, }, schema end end end diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb index 494663eb04..69be091869 100644 --- a/activerecord/test/cases/query_cache_test.rb +++ b/activerecord/test/cases/query_cache_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/task" @@ -11,12 +13,13 @@ class QueryCacheTest < ActiveRecord::TestCase fixtures :tasks, :topics, :categories, :posts, :categories_posts class ShouldNotHaveExceptionsLogger < ActiveRecord::LogSubscriber - attr_reader :logger + attr_reader :logger, :events def initialize super @logger = ::Logger.new File::NULL @exception = false + @events = [] end def exception? @@ -24,6 +27,7 @@ class QueryCacheTest < ActiveRecord::TestCase end def sql(event) + @events << event super rescue @exception = true @@ -42,7 +46,8 @@ class QueryCacheTest < ActiveRecord::TestCase mw = middleware { |env| Task.find 1 Task.find 1 - assert_equal 1, ActiveRecord::Base.connection.query_cache.length + query_cache = ActiveRecord::Base.connection.query_cache + assert_equal 1, query_cache.length, query_cache.keys raise "lol borked" } assert_raises(RuntimeError) { mw.call({}) } @@ -79,7 +84,7 @@ class QueryCacheTest < ActiveRecord::TestCase assert_cache :off, conn end - assert !ActiveRecord::Base.connection.nil? + assert_not_predicate ActiveRecord::Base.connection, :nil? assert_cache :off middleware { @@ -130,7 +135,7 @@ class QueryCacheTest < ActiveRecord::TestCase assert_cache :off, conn end ensure - ActiveRecord::Base.clear_all_connections! + ActiveRecord::Base.connection_pool.disconnect! end end end @@ -149,7 +154,8 @@ class QueryCacheTest < ActiveRecord::TestCase mw = middleware { |env| Task.find 1 Task.find 1 - assert_equal 1, ActiveRecord::Base.connection.query_cache.length + query_cache = ActiveRecord::Base.connection.query_cache + assert_equal 1, query_cache.length, query_cache.keys [200, {}, nil] } mw.call({}) @@ -204,6 +210,52 @@ class QueryCacheTest < ActiveRecord::TestCase end end + def test_exists_queries_with_cache + Post.cache do + assert_queries(1) { Post.exists?; Post.exists? } + end + end + + def test_select_all_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_all(Post.all) } + end + end + end + + def test_select_one_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_one(Post.all) } + end + end + end + + def test_select_value_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_value(Post.all) } + end + end + end + + def test_select_values_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_values(Post.all) } + end + end + end + + def test_select_rows_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_rows(Post.all) } + end + end + end + def test_query_cache_dups_results_correctly Task.cache do now = Time.now.utc @@ -215,6 +267,26 @@ class QueryCacheTest < ActiveRecord::TestCase end end + def test_cache_notifications_can_be_overridden + logger = ShouldNotHaveExceptionsLogger.new + subscriber = ActiveSupport::Notifications.subscribe "sql.active_record", logger + + connection = ActiveRecord::Base.connection.dup + + def connection.cache_notification_info(sql, name, binds) + super.merge(neat: true) + end + + connection.cache do + connection.select_all "select 1" + connection.select_all "select 1" + end + + assert_equal true, logger.events.last.payload[:neat] + ensure + ActiveSupport::Notifications.unsubscribe subscriber + end + def test_cache_does_not_raise_exceptions logger = ShouldNotHaveExceptionsLogger.new subscriber = ActiveSupport::Notifications.subscribe "sql.active_record", logger @@ -233,7 +305,7 @@ class QueryCacheTest < ActiveRecord::TestCase payload[:sql].downcase! end - assert_raises RuntimeError do + assert_raises frozen_error_class do ActiveRecord::Base.cache do assert_queries(1) { Task.find(1); Task.find(1) } end @@ -252,14 +324,10 @@ class QueryCacheTest < ActiveRecord::TestCase end end - def test_cache_does_not_wrap_string_results_in_arrays + def test_cache_does_not_wrap_results_in_arrays Task.cache do - # Oracle adapter returns count() as Integer or Float - if current_adapter?(:OracleAdapter) - assert_kind_of Numeric, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") - elsif current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter) - # Future versions of the sqlite3 adapter will return numeric - assert_instance_of 0.class, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") + if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter) + assert_equal 2, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") else assert_instance_of String, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") end @@ -285,25 +353,18 @@ class QueryCacheTest < ActiveRecord::TestCase ActiveRecord::Base.configurations = conf end - def test_cache_is_not_available_when_using_a_not_connected_connection + def test_cache_is_available_when_using_a_not_connected_connection + skip "In-Memory DB can't test for using a not connected connection" if in_memory_db? with_temporary_connection_pool do spec_name = Task.connection_specification_name conf = ActiveRecord::Base.configurations["arunit"].merge("name" => "test2") ActiveRecord::Base.connection_handler.establish_connection(conf) Task.connection_specification_name = "test2" - refute Task.connected? + assert_not_predicate Task, :connected? Task.cache do begin - if in_memory_db? - Task.connection.create_table :tasks do |t| - t.datetime :starting - t.datetime :ending - end - ActiveRecord::FixtureSet.create_fixtures(self.class.fixture_path, ["tasks"], {}, ActiveRecord::Base) - end - Task.connection # warmup postgresql connection setup queries - assert_queries(2) { Task.find(1); Task.find(1) } + assert_queries(1) { Task.find(1); Task.find(1) } ensure ActiveRecord::Base.connection_handler.remove_connection(Task.connection_specification_name) Task.connection_specification_name = spec_name @@ -338,7 +399,7 @@ class QueryCacheTest < ActiveRecord::TestCase post = Post.first Post.transaction do - post.update_attributes(title: "rollback") + post.update(title: "rollback") assert_equal 1, Post.where(title: "rollback").to_a.count raise ActiveRecord::Rollback end @@ -351,7 +412,7 @@ class QueryCacheTest < ActiveRecord::TestCase begin Post.transaction do - post.update_attributes(title: "rollback") + post.update(title: "rollback") assert_equal 1, Post.where(title: "rollback").to_a.count raise "broken" end @@ -370,10 +431,8 @@ class QueryCacheTest < ActiveRecord::TestCase # Warm the cache Task.find(1) - Task.connection.type_map.clear - # Preload the type cache again (so we don't have those queries issued during our assertions) - Task.connection.send(:initialize_type_map, Task.connection.type_map) + Task.connection.send(:reload_type_map) # Clear places where type information is cached Task.reset_column_information @@ -388,24 +447,25 @@ class QueryCacheTest < ActiveRecord::TestCase def test_query_cache_does_not_establish_connection_if_unconnected with_temporary_connection_pool do ActiveRecord::Base.clear_active_connections! - refute ActiveRecord::Base.connection_handler.active_connections? # sanity check + assert_not ActiveRecord::Base.connection_handler.active_connections? # sanity check middleware { - refute ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in setup" + assert_not ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in setup" }.call({}) - refute ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in cleanup" + assert_not ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in cleanup" end end def test_query_cache_is_enabled_on_connections_established_after_middleware_runs with_temporary_connection_pool do ActiveRecord::Base.clear_active_connections! - refute ActiveRecord::Base.connection_handler.active_connections? # sanity check + assert_not ActiveRecord::Base.connection_handler.active_connections? # sanity check middleware { - assert ActiveRecord::Base.connection.query_cache_enabled, "QueryCache did not get lazily enabled" + assert_predicate ActiveRecord::Base.connection, :query_cache_enabled }.call({}) + assert_not_predicate ActiveRecord::Base.connection, :query_cache_enabled end end @@ -418,14 +478,22 @@ class QueryCacheTest < ActiveRecord::TestCase assert ActiveRecord::Base.connection.query_cache_enabled Thread.new { - refute ActiveRecord::Base.connection_pool.query_cache_enabled - refute ActiveRecord::Base.connection.query_cache_enabled + assert_not ActiveRecord::Base.connection_pool.query_cache_enabled + assert_not ActiveRecord::Base.connection.query_cache_enabled }.join }.call({}) - end end + def test_query_cache_is_enabled_on_all_connection_pools + middleware { + ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool| + assert pool.query_cache_enabled + assert pool.connection.query_cache_enabled + end + }.call({}) + end + private def middleware(&app) executor = Class.new(ActiveSupport::Executor) @@ -436,14 +504,14 @@ class QueryCacheTest < ActiveRecord::TestCase def assert_cache(state, connection = ActiveRecord::Base.connection) case state when :off - assert !connection.query_cache_enabled, "cache should be off" + assert_not connection.query_cache_enabled, "cache should be off" assert connection.query_cache.empty?, "cache should be empty" when :clean assert connection.query_cache_enabled, "cache should be on" assert connection.query_cache.empty?, "cache should be empty" when :dirty assert connection.query_cache_enabled, "cache should be on" - assert !connection.query_cache.empty?, "cache should be dirty" + assert_not connection.query_cache.empty?, "cache should be dirty" else raise "unknown state" end @@ -471,19 +539,19 @@ class QueryCacheExpiryTest < ActiveRecord::TestCase def test_find assert_called(Task.connection, :clear_query_cache) do - assert !Task.connection.query_cache_enabled + assert_not Task.connection.query_cache_enabled Task.cache do assert Task.connection.query_cache_enabled Task.find(1) Task.uncached do - assert !Task.connection.query_cache_enabled + assert_not Task.connection.query_cache_enabled Task.find(1) end assert Task.connection.query_cache_enabled end - assert !Task.connection.query_cache_enabled + assert_not Task.connection.query_cache_enabled end end @@ -527,7 +595,7 @@ class QueryCacheExpiryTest < ActiveRecord::TestCase assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do ActiveRecord::Base.cache do p = Post.find(1) - assert p.categories.any? + assert_predicate p.categories, :any? p.categories.delete_all end end diff --git a/activerecord/test/cases/quoting_test.rb b/activerecord/test/cases/quoting_test.rb index f260d043e4..723fccc8d9 100644 --- a/activerecord/test/cases/quoting_test.rb +++ b/activerecord/test/cases/quoting_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -8,11 +10,11 @@ module ActiveRecord end def test_quoted_true - assert_equal "'t'", @quoter.quoted_true + assert_equal "TRUE", @quoter.quoted_true end def test_quoted_false - assert_equal "'f'", @quoter.quoted_false + assert_equal "FALSE", @quoter.quoted_false end def test_quote_column_name @@ -44,27 +46,86 @@ module ActiveRecord assert_equal t.to_s(:db), @quoter.quoted_date(t) end - def test_quoted_time_utc + def test_quoted_timestamp_utc with_timezone_config default: :utc do t = Time.now.change(usec: 0) assert_equal t.getutc.to_s(:db), @quoter.quoted_date(t) end end - def test_quoted_time_local + def test_quoted_timestamp_local with_timezone_config default: :local do t = Time.now.change(usec: 0) assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t) end end - def test_quoted_time_crazy + def test_quoted_timestamp_crazy with_timezone_config default: :asdfasdf do t = Time.now.change(usec: 0) assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t) end end + def test_quoted_time_utc + with_timezone_config default: :utc do + t = Time.now.change(usec: 0) + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getutc.to_s(:db).slice(11..-1) + + assert_equal expected, @quoter.quoted_time(t) + end + end + + def test_quoted_time_local + with_timezone_config default: :local do + t = Time.now.change(usec: 0) + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).sub("2000-01-01 ", "") + + assert_equal expected, @quoter.quoted_time(t) + end + end + + def test_quoted_time_dst_utc + with_env_tz "America/New_York" do + with_timezone_config default: :utc do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getutc.to_s(:db).slice(11..-1) + + assert_equal expected, @quoter.quoted_time(t) + end + end + end + + def test_quoted_time_dst_local + with_env_tz "America/New_York" do + with_timezone_config default: :local do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).slice(11..-1) + + assert_equal expected, @quoter.quoted_time(t) + end + end + end + + def test_quoted_time_crazy + with_timezone_config default: :asdfasdf do + t = Time.now.change(usec: 0) + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).sub("2000-01-01 ", "") + + assert_equal expected, @quoter.quoted_time(t) + end + end + def test_quoted_datetime_utc with_timezone_config default: :utc do t = Time.now.change(usec: 0).to_datetime @@ -81,10 +142,6 @@ module ActiveRecord end end - def test_quote_with_quoted_id - assert_deprecated { assert_equal 1, @quoter.quote(Struct.new(:quoted_id).new(1)) } - end - def test_quote_nil assert_equal "NULL", @quoter.quote(nil) end @@ -113,7 +170,7 @@ module ActiveRecord end def test_quote_bigdecimal - bigdec = BigDecimal.new((1 << 100).to_s) + bigdec = BigDecimal((1 << 100).to_s) assert_equal bigdec.to_s("F"), @quoter.quote(bigdec) end @@ -161,13 +218,21 @@ module ActiveRecord def test_type_cast_date date = Date.today - expected = @conn.quoted_date(date) + if current_adapter?(:Mysql2Adapter) + expected = date + else + expected = @conn.quoted_date(date) + end assert_equal expected, @conn.type_cast(date) end def test_type_cast_time time = Time.now - expected = @conn.quoted_date(time) + if current_adapter?(:Mysql2Adapter) + expected = time + else + expected = @conn.quoted_date(time) + end assert_equal expected, @conn.type_cast(time) end @@ -184,26 +249,6 @@ module ActiveRecord obj = Class.new.new assert_raise(TypeError) { @conn.type_cast(obj) } end - - def test_type_cast_object_which_responds_to_quoted_id - quoted_id_obj = Class.new { - def quoted_id - "'zomg'" - end - - def id - 10 - end - }.new - assert_equal 10, @conn.type_cast(quoted_id_obj) - - quoted_id_obj = Class.new { - def quoted_id - "'zomg'" - end - }.new - assert_raise(TypeError) { @conn.type_cast(quoted_id_obj) } - end end class QuoteBooleanTest < ActiveRecord::TestCase @@ -244,7 +289,7 @@ module ActiveRecord def test_type_cast_ar_object value = DatetimePrimaryKey.new(id: @time) - assert_equal "2017-02-14 12:34:56.789000", @connection.type_cast(value) + assert_equal @connection.type_cast(value.id), @connection.type_cast(value) end end end diff --git a/activerecord/test/cases/readonly_test.rb b/activerecord/test/cases/readonly_test.rb index a93061b516..059fa76132 100644 --- a/activerecord/test/cases/readonly_test.rb +++ b/activerecord/test/cases/readonly_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/author" require "models/post" @@ -10,18 +12,18 @@ require "models/person" require "models/ship" class ReadOnlyTest < ActiveRecord::TestCase - fixtures :authors, :posts, :comments, :developers, :projects, :developers_projects, :people, :readers + fixtures :authors, :author_addresses, :posts, :comments, :developers, :projects, :developers_projects, :people, :readers def test_cant_save_readonly_record dev = Developer.find(1) - assert !dev.readonly? + assert_not_predicate dev, :readonly? dev.readonly! - assert dev.readonly? + assert_predicate dev, :readonly? assert_nothing_raised do dev.name = "Luscious forbidden fruit." - assert !dev.save + assert_not dev.save dev.name = "Forbidden." end @@ -36,8 +38,8 @@ class ReadOnlyTest < ActiveRecord::TestCase end def test_find_with_readonly_option - Developer.all.each { |d| assert !d.readonly? } - Developer.readonly(false).each { |d| assert !d.readonly? } + Developer.all.each { |d| assert_not d.readonly? } + Developer.readonly(false).each { |d| assert_not d.readonly? } Developer.readonly(true).each { |d| assert d.readonly? } Developer.readonly.each { |d| assert d.readonly? } end @@ -52,56 +54,56 @@ class ReadOnlyTest < ActiveRecord::TestCase def test_has_many_find_readonly post = Post.find(1) - assert !post.comments.empty? - assert !post.comments.any?(&:readonly?) - assert !post.comments.to_a.any?(&:readonly?) + assert_not_empty post.comments + assert_not post.comments.any?(&:readonly?) + assert_not post.comments.to_a.any?(&:readonly?) assert post.comments.readonly(true).all?(&:readonly?) end def test_has_many_with_through_is_not_implicitly_marked_readonly assert people = Post.find(1).people - assert !people.any?(&:readonly?) + assert_not people.any?(&:readonly?) end def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_by_id - assert !posts(:welcome).people.find(1).readonly? + assert_not_predicate posts(:welcome).people.find(1), :readonly? end def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_first - assert !posts(:welcome).people.first.readonly? + assert_not_predicate posts(:welcome).people.first, :readonly? end def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_last - assert !posts(:welcome).people.last.readonly? + assert_not_predicate posts(:welcome).people.last, :readonly? end def test_readonly_scoping Post.where("1=1").scoping do - assert !Post.find(1).readonly? - assert Post.readonly(true).find(1).readonly? - assert !Post.readonly(false).find(1).readonly? + assert_not_predicate Post.find(1), :readonly? + assert_predicate Post.readonly(true).find(1), :readonly? + assert_not_predicate Post.readonly(false).find(1), :readonly? end Post.joins(" ").scoping do - assert !Post.find(1).readonly? - assert Post.readonly.find(1).readonly? - assert !Post.readonly(false).find(1).readonly? + assert_not_predicate Post.find(1), :readonly? + assert_predicate Post.readonly.find(1), :readonly? + assert_not_predicate Post.readonly(false).find(1), :readonly? end # Oracle barfs on this because the join includes unqualified and # conflicting column names unless current_adapter?(:OracleAdapter) Post.joins(", developers").scoping do - assert_not Post.find(1).readonly? - assert Post.readonly.find(1).readonly? - assert !Post.readonly(false).find(1).readonly? + assert_not_predicate Post.find(1), :readonly? + assert_predicate Post.readonly.find(1), :readonly? + assert_not_predicate Post.readonly(false).find(1), :readonly? end end Post.readonly(true).scoping do - assert Post.find(1).readonly? - assert Post.readonly.find(1).readonly? - assert !Post.readonly(false).find(1).readonly? + assert_predicate Post.find(1), :readonly? + assert_predicate Post.readonly.find(1), :readonly? + assert_not_predicate Post.readonly(false).find(1), :readonly? end end @@ -109,10 +111,10 @@ class ReadOnlyTest < ActiveRecord::TestCase developer = Developer.find(1) project = Post.find(1) - assert !developer.projects.all_as_method.first.readonly? - assert !developer.projects.all_as_scope.first.readonly? + assert_not_predicate developer.projects.all_as_method.first, :readonly? + assert_not_predicate developer.projects.all_as_scope.first, :readonly? - assert !project.comments.all_as_method.first.readonly? - assert !project.comments.all_as_scope.first.readonly? + assert_not_predicate project.comments.all_as_method.first, :readonly? + assert_not_predicate project.comments.all_as_scope.first, :readonly? end end diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 249878b67d..b630f782bc 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -16,6 +18,7 @@ module ActiveRecord class FakePool attr_reader :reaped + attr_reader :flushed def initialize @reaped = false @@ -24,20 +27,24 @@ module ActiveRecord def reap @reaped = true end + + def flush + @flushed = true + end end # A reaper with nil time should never reap connections def test_nil_time fp = FakePool.new - assert !fp.reaped + assert_not fp.reaped reaper = ConnectionPool::Reaper.new(fp, nil) reaper.run - assert !fp.reaped + assert_not fp.reaped end def test_some_time fp = FakePool.new - assert !fp.reaped + assert_not fp.reaped reaper = ConnectionPool::Reaper.new(fp, 0.0001) reaper.run @@ -45,6 +52,7 @@ module ActiveRecord Thread.pass end assert fp.reaped + assert fp.flushed end def test_pool_has_reaper @@ -53,9 +61,9 @@ module ActiveRecord def test_reaping_frequency_configuration spec = ActiveRecord::Base.connection_pool.spec.dup - spec.config[:reaping_frequency] = 100 + spec.config[:reaping_frequency] = "10.01" pool = ConnectionPool.new spec - assert_equal 100, pool.reaper.frequency + assert_equal 10.01, pool.reaper.frequency end def test_connection_pool_starts_reaper @@ -71,14 +79,14 @@ module ActiveRecord end Thread.pass while conn.nil? - assert conn.in_use? + assert_predicate conn, :in_use? child.terminate while conn.in_use? Thread.pass end - assert !conn.in_use? + assert_not_predicate conn, :in_use? end end end diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index c1c2efb9c8..abadafbad4 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/customer" @@ -23,7 +25,6 @@ require "models/chef" require "models/department" require "models/cake_designer" require "models/drink_designer" -require "models/mocktail_designer" require "models/recipe" class ReflectionTest < ActiveRecord::TestCase @@ -65,13 +66,16 @@ class ReflectionTest < ActiveRecord::TestCase def test_column_string_type_and_limit assert_equal :string, @first.column_for_attribute("title").type + assert_equal :string, @first.column_for_attribute(:title).type + assert_equal :string, @first.type_for_attribute("title").type + assert_equal :string, @first.type_for_attribute(:title).type assert_equal 250, @first.column_for_attribute("title").limit end def test_column_null_not_null subscriber = Subscriber.first assert subscriber.column_for_attribute("name").null - assert !subscriber.column_for_attribute("nick").null + assert_not subscriber.column_for_attribute("nick").null end def test_human_name_for_column @@ -80,6 +84,9 @@ class ReflectionTest < ActiveRecord::TestCase def test_integer_columns assert_equal :integer, @first.column_for_attribute("id").type + assert_equal :integer, @first.column_for_attribute(:id).type + assert_equal :integer, @first.type_for_attribute("id").type + assert_equal :integer, @first.type_for_attribute(:id).type end def test_non_existent_columns_return_null_object @@ -88,6 +95,9 @@ class ReflectionTest < ActiveRecord::TestCase assert_equal "attribute_that_doesnt_exist", column.name assert_nil column.sql_type assert_nil column.type + + column = @first.column_for_attribute(:attribute_that_doesnt_exist) + assert_instance_of ActiveRecord::ConnectionAdapters::NullColumn, column end def test_non_existent_types_are_identity_types @@ -97,6 +107,11 @@ class ReflectionTest < ActiveRecord::TestCase assert_equal object, type.deserialize(object) assert_equal object, type.cast(object) assert_equal object, type.serialize(object) + + type = @first.type_for_attribute(:attribute_that_doesnt_exist) + assert_equal object, type.deserialize(object) + assert_equal object, type.cast(object) + assert_equal object, type.serialize(object) end def test_reflection_klass_for_nested_class_name @@ -148,7 +163,7 @@ class ReflectionTest < ActiveRecord::TestCase expected = Pirate.reflect_on_all_associations.select { |r| r.options[:autosave] } received = Pirate.reflect_on_all_autosave_associations - assert !received.empty? + assert_not_empty received assert_not_equal Pirate.reflect_on_all_associations.length, received.length assert_equal expected, received end @@ -252,50 +267,35 @@ class ReflectionTest < ActiveRecord::TestCase assert_equal expected, actual end - def test_scope_chain - expected = [ - [Tagging.reflect_on_association(:tag).scope, Post.reflect_on_association(:first_blue_tags).scope], - [Post.reflect_on_association(:first_taggings).scope], - [Author.reflect_on_association(:misc_posts).scope] - ] - actual = assert_deprecated do - Author.reflect_on_association(:misc_post_first_blue_tags).scope_chain - end - assert_equal expected, actual - - expected = [ - [ - Tagging.reflect_on_association(:blue_tag).scope, - Post.reflect_on_association(:first_blue_tags_2).scope, - Author.reflect_on_association(:misc_post_first_blue_tags_2).scope - ], - [], - [] - ] - actual = assert_deprecated do - Author.reflect_on_association(:misc_post_first_blue_tags_2).scope_chain - end - assert_equal expected, actual - end - def test_scope_chain_does_not_interfere_with_hmt_with_polymorphic_case - @hotel = Hotel.create! - @department = @hotel.departments.create! - @department.chefs.create!(employable: CakeDesigner.create!) - @department.chefs.create!(employable: DrinkDesigner.create!) + hotel = Hotel.create! + department = hotel.departments.create! + department.chefs.create!(employable: CakeDesigner.create!) + department.chefs.create!(employable: DrinkDesigner.create!) - assert_equal 1, @hotel.cake_designers.size - assert_equal 1, @hotel.drink_designers.size - assert_equal 2, @hotel.chefs.size + assert_equal 1, hotel.cake_designers.size + assert_equal 1, hotel.cake_designers.count + assert_equal 1, hotel.drink_designers.size + assert_equal 1, hotel.drink_designers.count + assert_equal 2, hotel.chefs.size + assert_equal 2, hotel.chefs.count end def test_scope_chain_does_not_interfere_with_hmt_with_polymorphic_case_and_sti - @hotel = Hotel.create! - @hotel.mocktail_designers << MocktailDesigner.create! + hotel = Hotel.create! + hotel.mocktail_designers << MocktailDesigner.create! + + assert_equal 1, hotel.mocktail_designers.size + assert_equal 1, hotel.mocktail_designers.count + assert_equal 1, hotel.chef_lists.size + assert_equal 1, hotel.chef_lists.count - assert_equal 1, @hotel.mocktail_designers.size - assert_equal 1, @hotel.mocktail_designers.count - assert_equal 1, @hotel.chef_lists.size + hotel.mocktail_designers = [] + + assert_equal 0, hotel.mocktail_designers.size + assert_equal 0, hotel.mocktail_designers.count + assert_equal 0, hotel.chef_lists.size + assert_equal 0, hotel.chef_lists.count end def test_scope_chain_of_polymorphic_association_does_not_leak_into_other_hmt_associations @@ -315,12 +315,12 @@ class ReflectionTest < ActiveRecord::TestCase end def test_nested? - assert !Author.reflect_on_association(:comments).nested? - assert Author.reflect_on_association(:tags).nested? + assert_not_predicate Author.reflect_on_association(:comments), :nested? + assert_predicate Author.reflect_on_association(:tags), :nested? # Only goes :through once, but the through_reflection is a has_and_belongs_to_many, so this is # a nested through association - assert Category.reflect_on_association(:post_comments).nested? + assert_predicate Category.reflect_on_association(:post_comments), :nested? end def test_association_primary_key @@ -335,15 +335,6 @@ class ReflectionTest < ActiveRecord::TestCase assert_equal "custom_primary_key", Author.reflect_on_association(:tags_with_primary_key).association_primary_key.to_s # nested end - def test_association_primary_key_type - # Normal Association - assert_equal :integer, Author.reflect_on_association(:posts).association_primary_key_type.type - assert_equal :string, Author.reflect_on_association(:essay).association_primary_key_type.type - - # Through Association - assert_equal :string, Author.reflect_on_association(:essay_category).association_primary_key_type.type - end - def test_association_primary_key_raises_when_missing_primary_key reflection = ActiveRecord::Reflection.create(:has_many, :edge, nil, {}, Author) assert_raises(ActiveRecord::UnknownPrimaryKey) { reflection.association_primary_key } @@ -364,42 +355,49 @@ class ReflectionTest < ActiveRecord::TestCase assert_raises(ActiveRecord::UnknownPrimaryKey) { reflection.active_record_primary_key } end + def test_type + assert_equal "taggable_type", Post.reflect_on_association(:taggings).type.to_s + assert_equal "imageable_class", Post.reflect_on_association(:images).type.to_s + assert_nil Post.reflect_on_association(:readers).type + end + def test_foreign_type assert_equal "sponsorable_type", Sponsor.reflect_on_association(:sponsorable).foreign_type.to_s assert_equal "sponsorable_type", Sponsor.reflect_on_association(:thing).foreign_type.to_s + assert_nil Sponsor.reflect_on_association(:sponsor_club).foreign_type end def test_collection_association - assert Pirate.reflect_on_association(:birds).collection? - assert Pirate.reflect_on_association(:parrots).collection? + assert_predicate Pirate.reflect_on_association(:birds), :collection? + assert_predicate Pirate.reflect_on_association(:parrots), :collection? - assert !Pirate.reflect_on_association(:ship).collection? - assert !Ship.reflect_on_association(:pirate).collection? + assert_not_predicate Pirate.reflect_on_association(:ship), :collection? + assert_not_predicate Ship.reflect_on_association(:pirate), :collection? end def test_default_association_validation - assert ActiveRecord::Reflection.create(:has_many, :clients, nil, {}, Firm).validate? + assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, {}, Firm), :validate? - assert !ActiveRecord::Reflection.create(:has_one, :client, nil, {}, Firm).validate? - assert !ActiveRecord::Reflection.create(:belongs_to, :client, nil, {}, Firm).validate? + assert_not_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, {}, Firm), :validate? + assert_not_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, {}, Firm), :validate? end def test_always_validate_association_if_explicit - assert ActiveRecord::Reflection.create(:has_one, :client, nil, { validate: true }, Firm).validate? - assert ActiveRecord::Reflection.create(:belongs_to, :client, nil, { validate: true }, Firm).validate? - assert ActiveRecord::Reflection.create(:has_many, :clients, nil, { validate: true }, Firm).validate? + assert_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { validate: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { validate: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { validate: true }, Firm), :validate? end def test_validate_association_if_autosave - assert ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true }, Firm).validate? - assert ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true }, Firm).validate? - assert ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true }, Firm).validate? + assert_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true }, Firm), :validate? end def test_never_validate_association_if_explicit - assert !ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true, validate: false }, Firm).validate? - assert !ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true, validate: false }, Firm).validate? - assert !ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true, validate: false }, Firm).validate? + assert_not_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true, validate: false }, Firm), :validate? + assert_not_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true, validate: false }, Firm), :validate? + assert_not_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true, validate: false }, Firm), :validate? end def test_foreign_key @@ -407,26 +405,15 @@ class ReflectionTest < ActiveRecord::TestCase assert_equal "category_id", Post.reflect_on_association(:categorizations).foreign_key.to_s end - def test_through_reflection_scope_chain_does_not_modify_other_reflections - orig_conds = assert_deprecated do - Post.reflect_on_association(:first_blue_tags_2).scope_chain - end.inspect - assert_deprecated do - Author.reflect_on_association(:misc_post_first_blue_tags_2).scope_chain - end - assert_equal orig_conds, assert_deprecated { - Post.reflect_on_association(:first_blue_tags_2).scope_chain - }.inspect - end - def test_symbol_for_class_name assert_equal Client, Firm.reflect_on_association(:unsorted_clients_with_symbol).klass end def test_class_for_class_name - assert_deprecated do - assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { class_name: Client }, Firm), :validate? + error = assert_raises(ArgumentError) do + ActiveRecord::Reflection.create(:has_many, :clients, nil, { class_name: Client }, Firm) end + assert_equal "A class was passed to `:class_name` but we are expecting a string.", error.message end def test_join_table diff --git a/activerecord/test/cases/relation/delegation_test.rb b/activerecord/test/cases/relation/delegation_test.rb index 49d4aeafc9..a8030c2d64 100644 --- a/activerecord/test/cases/relation/delegation_test.rb +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -1,39 +1,19 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" module ActiveRecord - class DelegationTest < ActiveRecord::TestCase - fixtures :posts - - def call_method(target, method) - method_arity = target.to_a.method(method).arity - - if method_arity.zero? - target.public_send(method) - elsif method_arity < 0 - if method == :shuffle! - target.public_send(method) - else - target.public_send(method, 1) - end - elsif method_arity == 1 - target.public_send(method, 1) - else - raise NotImplementedError - end - end - end - - module DelegationWhitelistBlacklistTests + module ArrayDelegationTests ARRAY_DELEGATES = [ :+, :-, :|, :&, :[], :shuffle, :all?, :collect, :compact, :detect, :each, :each_cons, :each_with_index, :exclude?, :find_all, :flat_map, :group_by, :include?, :length, - :map, :none?, :one?, :partition, :reject, :reverse, - :sample, :second, :sort, :sort_by, :third, + :map, :none?, :one?, :partition, :reject, :reverse, :rotate, + :sample, :second, :sort, :sort_by, :slice, :third, :index, :rindex, :to_ary, :to_set, :to_xml, :to_yaml, :join, - :in_groups, :in_groups_of, :to_sentence, :to_formatted_s + :in_groups, :in_groups_of, :to_sentence, :to_formatted_s, :as_json ] ARRAY_DELEGATES.each do |method| @@ -43,21 +23,63 @@ module ActiveRecord end end - class DelegationAssociationTest < DelegationTest - include DelegationWhitelistBlacklistTests + module DeprecatedArelDelegationTests + AREL_METHODS = [ + :with, :orders, :froms, :project, :projections, :taken, :constraints, :exists, :locked, :where_sql, + :ast, :source, :join_sources, :to_dot, :create_insert, :create_true, :create_false + ] - def target - Post.first.comments + def test_deprecate_arel_delegation + AREL_METHODS.each do |method| + assert_deprecated { target.public_send(method) } + assert_deprecated { target.public_send(method) } + end end end - class DelegationRelationTest < DelegationTest - include DelegationWhitelistBlacklistTests + class DelegationAssociationTest < ActiveRecord::TestCase + include ArrayDelegationTests + include DeprecatedArelDelegationTests + + def target + Post.new.comments + end + end - fixtures :comments + class DelegationRelationTest < ActiveRecord::TestCase + include ArrayDelegationTests + include DeprecatedArelDelegationTests def target Comment.all end end + + class QueryingMethodsDelegationTest < ActiveRecord::TestCase + QUERYING_METHODS = [ + :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, + :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, + :find_by, :find_by!, + :destroy_all, :delete_all, :update_all, + :find_each, :find_in_batches, :in_batches, + :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, + :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, + :having, :create_with, :distinct, :references, :none, :unscope, :merge, + :count, :average, :minimum, :maximum, :sum, :calculate, + :pluck, :pick, :ids, + ] + + def test_delegate_querying_methods + klass = Class.new(ActiveRecord::Base) do + self.table_name = "posts" + end + + QUERYING_METHODS.each do |method| + assert_respond_to klass.all, method + assert_respond_to klass, method + end + end + end end diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb index 278dac8171..6e7998d15a 100644 --- a/activerecord/test/cases/relation/merging_test.rb +++ b/activerecord/test/cases/relation/merging_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/author" require "models/comment" @@ -8,7 +10,7 @@ require "models/project" require "models/rating" class RelationMergingTest < ActiveRecord::TestCase - fixtures :developers, :comments, :authors, :posts + fixtures :developers, :comments, :authors, :author_addresses, :posts def test_relation_merging devs = Developer.where("salary >= 80000").merge(Developer.limit(2)).merge(Developer.order("id ASC").where("id < 3")) @@ -21,7 +23,7 @@ class RelationMergingTest < ActiveRecord::TestCase def test_relation_to_sql post = Post.first sql = post.comments.to_sql - assert_match(/.?post_id.? = #{post.id}\Z/i, sql) + assert_match(/.?post_id.? = #{post.id}\z/i, sql) end def test_relation_merging_with_arel_equalities_keeps_last_equality @@ -56,7 +58,7 @@ class RelationMergingTest < ActiveRecord::TestCase def test_relation_merging_with_locks devs = Developer.lock.where("salary >= 80000").order("id DESC").merge(Developer.limit(2)) - assert devs.locked.present? + assert_predicate devs, :locked? end def test_relation_merging_with_preload @@ -70,6 +72,16 @@ class RelationMergingTest < ActiveRecord::TestCase assert_equal 1, comments.count end + def test_relation_merging_with_left_outer_joins + comments = Comment.joins(:post).where(body: "Thank you for the welcome").merge(Post.left_outer_joins(:author).where(body: "Such a lovely day")) + + assert_equal 1, comments.count + end + + def test_relation_merging_with_skip_query_cache + assert_equal Post.all.merge(Post.all.skip_query_cache!).skip_query_cache_value, true + end + def test_relation_merging_with_association assert_queries(2) do # one for loading post, and another one merged query post = Post.where(body: "Such a lovely day").first @@ -79,13 +91,11 @@ class RelationMergingTest < ActiveRecord::TestCase end test "merge collapses wheres from the LHS only" do - left = Post.where(title: "omg").where(comments_count: 1) + left = Post.where(title: "omg").where(comments_count: 1) right = Post.where(title: "wtf").where(title: "bbq") - expected = [left.bound_attributes[1]] + right.bound_attributes - merged = left.merge(right) + merged = left.merge(right) - assert_equal expected, merged.bound_attributes assert_not_includes merged.to_sql, "omg" assert_includes merged.to_sql, "wtf" assert_includes merged.to_sql, "bbq" @@ -107,14 +117,24 @@ class RelationMergingTest < ActiveRecord::TestCase def test_merging_with_from_clause relation = Post.all - assert relation.from_clause.empty? + assert_empty relation.from_clause relation = relation.merge(Post.from("posts")) - refute relation.from_clause.empty? + assert_not_empty relation.from_clause + end + + def test_merging_with_order_with_binds + relation = Post.all.merge(Post.order([Arel.sql("title LIKE ?"), "%suffix"])) + assert_equal ["title LIKE '%suffix'"], relation.order_values + end + + def test_merging_with_order_without_binds + relation = Post.all.merge(Post.order(Arel.sql("title LIKE '%?'"))) + assert_equal ["title LIKE '%?'"], relation.order_values end end class MergingDifferentRelationsTest < ActiveRecord::TestCase - fixtures :posts, :authors, :developers + fixtures :posts, :authors, :author_addresses, :developers test "merging where relations" do hello_by_bob = Post.where(body: "hello").joins(:author). @@ -143,7 +163,7 @@ class MergingDifferentRelationsTest < ActiveRecord::TestCase assert_equal ["Mary", "Mary", "Mary", "David"], posts_by_author_name end - test "relation merging (using a proc argument)" do + test "relation merging (using a proc argument)" do dev = Developer.where(name: "Jamis").first comment_1 = dev.comments.create!(body: "I'm Jamis", post: Post.first) diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb index 4f92f71a09..f82ecd4449 100644 --- a/activerecord/test/cases/relation/mutation_test.rb +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -1,42 +1,11 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" module ActiveRecord class RelationMutationTest < ActiveRecord::TestCase - FakeKlass = Struct.new(:table_name, :name) do - extend ActiveRecord::Delegation::DelegateCache - inherited self - - def connection - Post.connection - end - - def relation_delegate_class(klass) - self.class.relation_delegate_class(klass) - end - - def attribute_alias?(name) - false - end - - def sanitize_sql(sql) - sql - end - - def sanitize_sql_for_order(sql) - sql - end - - def arel_attribute(name, table) - table[name] - end - end - - def relation - @relation ||= Relation.new FakeKlass.new("posts"), Post.arel_table, Post.predicate_builder - end - - (Relation::MULTI_VALUE_METHODS - [:references, :extending, :order, :unscope, :select, :left_joins]).each do |method| + (Relation::MULTI_VALUE_METHODS - [:references, :extending, :order, :unscope, :select]).each do |method| test "##{method}!" do assert relation.public_send("#{method}!", :foo).equal?(relation) assert_equal [:foo], relation.public_send("#{method}_values") @@ -56,7 +25,7 @@ module ActiveRecord test "#order! with symbol prepends the table name" do assert relation.order!(:name).equal?(relation) node = relation.order_values.first - assert node.ascending? + assert_predicate node, :ascending? assert_equal :name, node.expr.name assert_equal "posts", node.expr.relation.name end @@ -90,7 +59,7 @@ module ActiveRecord assert_equal [], relation.extending_values end - (Relation::SINGLE_VALUE_METHODS - [:lock, :reordering, :reverse_order, :create_with]).each do |method| + (Relation::SINGLE_VALUE_METHODS - [:lock, :reordering, :reverse_order, :create_with, :skip_query_cache]).each do |method| test "##{method}!" do assert relation.public_send("#{method}!", :foo).equal?(relation) assert_equal :foo, relation.public_send("#{method}_value") @@ -119,7 +88,7 @@ module ActiveRecord assert relation.reorder!(:name).equal?(relation) node = relation.order_values.first - assert node.ascending? + assert_predicate node, :ascending? assert_equal :name, node.expr.name assert_equal "posts", node.expr.relation.name end @@ -143,7 +112,7 @@ module ActiveRecord assert_equal({ foo: "bar" }, relation.create_with_value) end - test "test_merge!" do + test "merge!" do assert relation.merge!(select: :foo).equal?(relation) assert_equal [:foo], relation.select_values end @@ -162,5 +131,20 @@ module ActiveRecord relation.distinct! :foo assert_equal :foo, relation.distinct_value end + + test "skip_query_cache!" do + relation.skip_query_cache! + assert relation.skip_query_cache_value + end + + test "skip_preloading!" do + relation.skip_preloading! + assert relation.skip_preloading_value + end + + private + def relation + @relation ||= Relation.new(FakeKlass) + end end end diff --git a/activerecord/test/cases/relation/or_test.rb b/activerecord/test/cases/relation/or_test.rb index abb7ca72dd..065819e0f1 100644 --- a/activerecord/test/cases/relation/or_test.rb +++ b/activerecord/test/cases/relation/or_test.rb @@ -1,9 +1,14 @@ +# frozen_string_literal: true + require "cases/helper" +require "models/author" +require "models/categorization" require "models/post" module ActiveRecord class OrTest < ActiveRecord::TestCase fixtures :posts + fixtures :authors, :author_addresses def test_or_with_relation expected = Post.where("id = 1 or id = 2").to_a @@ -26,7 +31,7 @@ module ActiveRecord end def test_or_with_bind_params - assert_equal Post.find([1, 2]), Post.where(id: 1).or(Post.where(id: 2)).to_a + assert_equal Post.find([1, 2]).sort_by(&:id), Post.where(id: 1).or(Post.where(id: 2)).sort_by(&:id) end def test_or_with_null_both @@ -59,6 +64,31 @@ module ActiveRecord assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message end + def test_or_with_unscope_where + expected = Post.where("id = 1 or id = 2") + partial = Post.where("id = 1 and id != 2") + assert_equal expected, partial.or(partial.unscope(:where).where("id = 2")).to_a + end + + def test_or_with_unscope_where_column + expected = Post.where("id = 1 or id = 2") + partial = Post.where(id: 1).where.not(id: 2) + assert_equal expected, partial.or(partial.unscope(where: :id).where("id = 2")).to_a + end + + def test_or_with_unscope_order + expected = Post.where("id = 1 or id = 2") + assert_equal expected, Post.order("body asc").where("id = 1").unscope(:order).or(Post.where("id = 2")).to_a + end + + def test_or_with_incompatible_unscope + error = assert_raises ArgumentError do + Post.order("body asc").where("id = 1").or(Post.order("body asc").where("id = 2").unscope(:order)).to_a + end + + assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message + end + def test_or_when_grouping groups = Post.where("id < 10").group("body").select("body, COUNT(*) AS c") expected = groups.having("COUNT(*) > 1 OR body like 'Such%'").to_a.map { |o| [o.body, o.c] } @@ -88,5 +118,20 @@ module ActiveRecord Post.where(id: [1, 2, 3]).or(title: "Rails") end end + + def test_or_with_references_inequality + joined = Post.includes(:author) + actual = joined.where(authors: { id: 1 }) + .or(joined.where(title: "I don't have any comments")) + expected = Author.find(1).posts + Post.where(title: "I don't have any comments") + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + + def test_or_with_scope_on_association + author = Author.first + assert_nothing_raised do + author.top_posts.or(author.other_top_posts) + end + end end end diff --git a/activerecord/test/cases/relation/predicate_builder_test.rb b/activerecord/test/cases/relation/predicate_builder_test.rb index 48758dc148..b432330deb 100644 --- a/activerecord/test/cases/relation/predicate_builder_test.rb +++ b/activerecord/test/cases/relation/predicate_builder_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" diff --git a/activerecord/test/cases/relation/record_fetch_warning_test.rb b/activerecord/test/cases/relation/record_fetch_warning_test.rb index 62ca051431..22d32d75bc 100644 --- a/activerecord/test/cases/relation/record_fetch_warning_test.rb +++ b/activerecord/test/cases/relation/record_fetch_warning_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "active_record/relation/record_fetch_warning" diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb new file mode 100644 index 0000000000..dec8a6925d --- /dev/null +++ b/activerecord/test/cases/relation/select_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +module ActiveRecord + class SelectTest < ActiveRecord::TestCase + fixtures :posts + + def test_select_with_nil_argument + expected = Post.select(:title).to_sql + assert_equal expected, Post.select(nil).select(:title).to_sql + end + end +end diff --git a/activerecord/test/cases/relation/where_chain_test.rb b/activerecord/test/cases/relation/where_chain_test.rb index a96d1ae5b5..a68eb2b446 100644 --- a/activerecord/test/cases/relation/where_chain_test.rb +++ b/activerecord/test/cases/relation/where_chain_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" @@ -25,7 +27,7 @@ module ActiveRecord end def test_association_not_eq - expected = Arel::Nodes::Grouping.new(Comment.arel_table[@name].not_eq(Arel::Nodes::BindParam.new)) + expected = Comment.arel_table[@name].not_eq(Arel::Nodes::BindParam.new(1)) relation = Post.joins(:comments).where.not(comments: { title: "hello" }) assert_equal(expected.to_sql, relation.where_clause.ast.to_sql) end diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb index d8e4c304f0..8703d238a0 100644 --- a/activerecord/test/cases/relation/where_clause_test.rb +++ b/activerecord/test/cases/relation/where_clause_test.rb @@ -1,78 +1,86 @@ +# frozen_string_literal: true + require "cases/helper" class ActiveRecord::Relation class WhereClauseTest < ActiveRecord::TestCase test "+ combines two where clauses" do - first_clause = WhereClause.new([table["id"].eq(bind_param)], [["id", 1]]) - second_clause = WhereClause.new([table["name"].eq(bind_param)], [["name", "Sean"]]) + first_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + second_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) combined = WhereClause.new( - [table["id"].eq(bind_param), table["name"].eq(bind_param)], - [["id", 1], ["name", "Sean"]], + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], ) assert_equal combined, first_clause + second_clause end test "+ is associative, but not commutative" do - a = WhereClause.new(["a"], ["bind a"]) - b = WhereClause.new(["b"], ["bind b"]) - c = WhereClause.new(["c"], ["bind c"]) + a = WhereClause.new(["a"]) + b = WhereClause.new(["b"]) + c = WhereClause.new(["c"]) assert_equal a + (b + c), (a + b) + c assert_not_equal a + b, b + a end test "an empty where clause is the identity value for +" do - clause = WhereClause.new([table["id"].eq(bind_param)], [["id", 1]]) + clause = WhereClause.new([table["id"].eq(bind_param(1))]) assert_equal clause, clause + WhereClause.empty end test "merge combines two where clauses" do - a = WhereClause.new([table["id"].eq(1)], []) - b = WhereClause.new([table["name"].eq("Sean")], []) - expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")], []) + a = WhereClause.new([table["id"].eq(1)]) + b = WhereClause.new([table["name"].eq("Sean")]) + expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")]) assert_equal expected, a.merge(b) end test "merge keeps the right side, when two equality clauses reference the same column" do - a = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")], []) - b = WhereClause.new([table["name"].eq("Jim")], []) - expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Jim")], []) + a = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")]) + b = WhereClause.new([table["name"].eq("Jim")]) + expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Jim")]) assert_equal expected, a.merge(b) end test "merge removes bind parameters matching overlapping equality clauses" do a = WhereClause.new( - [table["id"].eq(bind_param), table["name"].eq(bind_param)], - [attribute("id", 1), attribute("name", "Sean")], + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], ) b = WhereClause.new( - [table["name"].eq(bind_param)], - [attribute("name", "Jim")] + [table["name"].eq(bind_param("Jim"))], ) expected = WhereClause.new( - [table["id"].eq(bind_param), table["name"].eq(bind_param)], - [attribute("id", 1), attribute("name", "Jim")], + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Jim"))], ) assert_equal expected, a.merge(b) end test "merge allows for columns with the same name from different tables" do - skip "This is not possible as of 4.2, and the binds do not yet contain sufficient information for this to happen" - # We might be able to change the implementation to remove conflicts by index, rather than column name + table2 = Arel::Table.new("table2") + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table2["id"].eq(bind_param(2))], + ) + b = WhereClause.new( + [table["id"].eq(bind_param(3))], + ) + expected = WhereClause.new( + [table2["id"].eq(bind_param(2)), table["id"].eq(bind_param(3))], + ) + + assert_equal expected, a.merge(b) end test "a clause knows if it is empty" do - assert WhereClause.empty.empty? - assert_not WhereClause.new(["anything"], []).empty? + assert_empty WhereClause.empty + assert_not_empty WhereClause.new(["anything"]) end test "invert cannot handle nil" do - where_clause = WhereClause.new([nil], []) + where_clause = WhereClause.new([nil]) assert_raises ArgumentError do where_clause.invert @@ -86,37 +94,47 @@ class ActiveRecord::Relation table["id"].eq(1), "sql literal", random_object - ], []) + ]) expected = WhereClause.new([ table["id"].not_in([1, 2, 3]), table["id"].not_eq(1), Arel::Nodes::Not.new(Arel::Nodes::SqlLiteral.new("sql literal")), Arel::Nodes::Not.new(random_object) - ], []) + ]) assert_equal expected, original.invert end - test "accept removes binary predicates referencing a given column" do + test "except removes binary predicates referencing a given column" do where_clause = WhereClause.new([ table["id"].in([1, 2, 3]), - table["name"].eq(bind_param), - table["age"].gteq(bind_param), - ], [ - attribute("name", "Sean"), - attribute("age", 30), + table["name"].eq(bind_param("Sean")), + table["age"].gteq(bind_param(30)), ]) - expected = WhereClause.new([table["age"].gteq(bind_param)], [attribute("age", 30)]) + expected = WhereClause.new([table["age"].gteq(bind_param(30))]) assert_equal expected, where_clause.except("id", "name") end + test "except jumps over unhandled binds (like with OR) correctly" do + wcs = (0..9).map do |i| + WhereClause.new([table["id#{i}"].eq(bind_param(i))]) + end + + wc = wcs[0] + wcs[1] + wcs[2].or(wcs[3]) + wcs[4] + wcs[5] + wcs[6].or(wcs[7]) + wcs[8] + wcs[9] + + expected = wcs[0] + wcs[2].or(wcs[3]) + wcs[5] + wcs[6].or(wcs[7]) + wcs[9] + actual = wc.except("id1", "id2", "id4", "id7", "id8") + + assert_equal expected, actual + end + test "ast groups its predicates with AND" do predicates = [ table["id"].in([1, 2, 3]), - table["name"].eq(bind_param), + table["name"].eq(bind_param(nil)), ] - where_clause = WhereClause.new(predicates, []) + where_clause = WhereClause.new(predicates) expected = Arel::Nodes::And.new(predicates) assert_equal expected, where_clause.ast @@ -128,55 +146,96 @@ class ActiveRecord::Relation table["id"].in([1, 2, 3]), "foo = bar", random_object, - ], []) + ]) expected = Arel::Nodes::And.new([ table["id"].in([1, 2, 3]), Arel::Nodes::Grouping.new(Arel.sql("foo = bar")), - Arel::Nodes::Grouping.new(random_object), + random_object, ]) assert_equal expected, where_clause.ast end test "ast removes any empty strings" do - where_clause = WhereClause.new([table["id"].in([1, 2, 3])], []) - where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), ""], []) + where_clause = WhereClause.new([table["id"].in([1, 2, 3])]) + where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), ""]) assert_equal where_clause.ast, where_clause_with_empty.ast end test "or joins the two clauses using OR" do - where_clause = WhereClause.new([table["id"].eq(bind_param)], [attribute("id", 1)]) - other_clause = WhereClause.new([table["name"].eq(bind_param)], [attribute("name", "Sean")]) + where_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + other_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) expected_ast = Arel::Nodes::Grouping.new( - Arel::Nodes::Or.new(table["id"].eq(bind_param), table["name"].eq(bind_param)) + Arel::Nodes::Or.new(table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))) ) - expected_binds = where_clause.binds + other_clause.binds assert_equal expected_ast.to_sql, where_clause.or(other_clause).ast.to_sql - assert_equal expected_binds, where_clause.or(other_clause).binds end test "or returns an empty where clause when either side is empty" do - where_clause = WhereClause.new([table["id"].eq(bind_param)], [attribute("id", 1)]) + where_clause = WhereClause.new([table["id"].eq(bind_param(1))]) assert_equal WhereClause.empty, where_clause.or(WhereClause.empty) assert_equal WhereClause.empty, WhereClause.empty.or(where_clause) end + test "or places common conditions before the OR" do + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + b = WhereClause.new( + [table["id"].eq(bind_param(1)), table["hair_color"].eq(bind_param("black"))], + ) + + common = WhereClause.new( + [table["id"].eq(bind_param(1))], + ) + + or_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))])) + + assert_equal common + or_clause, a.or(b) + end + + test "or can detect identical or as being a common condition" do + common_or = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))])) + + a = common_or + WhereClause.new([table["id"].eq(bind_param(1))]) + b = common_or + WhereClause.new([table["foo"].eq(bind_param("bar"))]) + + new_or = WhereClause.new([table["id"].eq(bind_param(1))]) + .or(WhereClause.new([table["foo"].eq(bind_param("bar"))])) + + assert_equal common_or + new_or, a.or(b) + end + + test "or will use only common conditions if one side only has common conditions" do + only_common = WhereClause.new([ + table["id"].eq(bind_param(1)), + "foo = bar", + ]) + + common_with_extra = WhereClause.new([ + table["id"].eq(bind_param(1)), + "foo = bar", + table["extra"].eq(bind_param("pluto")), + ]) + + assert_equal only_common, only_common.or(common_with_extra) + assert_equal only_common, common_with_extra.or(only_common) + end + private def table Arel::Table.new("table") end - def bind_param - Arel::Nodes::BindParam.new - end - - def attribute(name, value) - ActiveRecord::Attribute.with_cast_value(name, value, ActiveRecord::Type::Value.new) + def bind_param(value) + Arel::Nodes::BindParam.new(value) end end end diff --git a/activerecord/test/cases/relation/where_test.rb b/activerecord/test/cases/relation/where_test.rb index ed8ffddcf5..99797528b2 100644 --- a/activerecord/test/cases/relation/where_test.rb +++ b/activerecord/test/cases/relation/where_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/author" require "models/binary" @@ -15,7 +17,7 @@ require "models/vertex" module ActiveRecord class WhereTest < ActiveRecord::TestCase - fixtures :posts, :edges, :authors, :binaries, :essays, :cars, :treasures, :price_estimates + fixtures :posts, :edges, :authors, :author_addresses, :binaries, :essays, :cars, :treasures, :price_estimates, :topics def test_where_copies_bind_params author = authors(:david) @@ -48,6 +50,10 @@ module ActiveRecord assert_equal [chef], chefs.to_a end + def test_where_with_casted_value_is_nil + assert_equal 4, Topic.where(last_read: "").count + end + def test_rewhere_on_root assert_equal posts(:welcome), Post.rewhere(title: "Welcome to the weblog").first end @@ -103,6 +109,15 @@ module ActiveRecord assert_equal expected.to_sql, actual.to_sql end + def test_polymorphic_shallow_where_not + treasure = treasures(:sapphire) + + expected = [price_estimates(:diamond), price_estimates(:honda)] + actual = PriceEstimate.where.not(estimate_of: treasure) + + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + def test_polymorphic_nested_array_where treasure = Treasure.new treasure.id = 1 @@ -115,13 +130,23 @@ module ActiveRecord assert_equal expected.to_sql, actual.to_sql end + def test_polymorphic_nested_array_where_not + treasure = treasures(:diamond) + car = cars(:honda) + + expected = [price_estimates(:sapphire_1), price_estimates(:sapphire_2)] + actual = PriceEstimate.where.not(estimate_of: [treasure, car]) + + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + def test_polymorphic_array_where_multiple_types treasure_1 = treasures(:diamond) treasure_2 = treasures(:sapphire) car = cars(:honda) expected = [price_estimates(:diamond), price_estimates(:sapphire_1), price_estimates(:sapphire_2), price_estimates(:honda)].sort - actual = PriceEstimate.where(estimate_of: [treasure_1, treasure_2, car]).to_a.sort + actual = PriceEstimate.where(estimate_of: [treasure_1, treasure_2, car]).to_a.sort assert_equal expected, actual end @@ -240,7 +265,7 @@ module ActiveRecord end def test_where_with_decimal_for_string_column - count = Post.where(title: BigDecimal.new(0)).count + count = Post.where(title: BigDecimal(0)).count assert_equal 0, count end @@ -289,6 +314,25 @@ module ActiveRecord assert_equal essays(:david_modest_proposal), essay end + def test_where_with_relation_on_has_many_association + essay = essays(:david_modest_proposal) + author = Author.where(essays: Essay.where(id: essay.id)).first + + assert_equal authors(:david), author + end + + def test_where_with_relation_on_has_one_association + author = authors(:david) + author_address = AuthorAddress.where(author: Author.where(id: author.id)).first + assert_equal author_addresses(:david_address), author_address + end + + + def test_where_on_association_with_select_relation + essay = Essay.where(author: Author.where(name: "David").select(:name)).take + assert_equal essays(:david_modest_proposal), essay + end + def test_where_with_strong_parameters protected_params = Class.new do attr_reader :permitted diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 1241bb54a4..fbeb617b29 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -1,56 +1,40 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" require "models/author" require "models/rating" +require "models/categorization" module ActiveRecord class RelationTest < ActiveRecord::TestCase - fixtures :posts, :comments, :authors - - FakeKlass = Struct.new(:table_name, :name) do - extend ActiveRecord::Delegation::DelegateCache - - inherited self - - def self.connection - Post.connection - end - - def self.table_name - "fake_table" - end - - def self.sanitize_sql_for_order(sql) - sql - end - end + fixtures :posts, :comments, :authors, :author_addresses, :ratings, :categorizations def test_construction - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass, table: :b) assert_equal FakeKlass, relation.klass assert_equal :b, relation.table - assert !relation.loaded, "relation is not loaded" + assert_not relation.loaded, "relation is not loaded" end def test_responds_to_model_and_returns_klass - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) assert_equal FakeKlass, relation.model end def test_initialize_single_values - relation = Relation.new(FakeKlass, :b, nil) - (Relation::SINGLE_VALUE_METHODS - [:create_with, :readonly]).each do |method| + relation = Relation.new(FakeKlass) + (Relation::SINGLE_VALUE_METHODS - [:create_with]).each do |method| assert_nil relation.send("#{method}_value"), method.to_s end - assert_equal false, relation.readonly_value value = relation.create_with_value assert_equal({}, value) assert_predicate value, :frozen? end def test_multi_value_initialize - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) Relation::MULTI_VALUE_METHODS.each do |method| values = relation.send("#{method}_values") assert_equal [], values, method.to_s @@ -59,70 +43,64 @@ module ActiveRecord end def test_extensions - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) assert_equal [], relation.extensions end def test_empty_where_values_hash - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) assert_equal({}, relation.where_values_hash) end def test_has_values - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) - relation.where! relation.table[:id].eq(10) - assert_equal({ id: 10 }, relation.where_values_hash) + relation = Relation.new(Post) + relation.where!(id: 10) + assert_equal({ "id" => 10 }, relation.where_values_hash) end def test_values_wrong_table - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) + relation = Relation.new(Post) relation.where! Comment.arel_table[:id].eq(10) assert_equal({}, relation.where_values_hash) end def test_tree_is_not_traversed - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) + relation = Relation.new(Post) left = relation.table[:id].eq(10) right = relation.table[:id].eq(10) - combine = left.and right + combine = left.or(right) relation.where! combine assert_equal({}, relation.where_values_hash) end - def test_table_name_delegates_to_klass - relation = Relation.new(FakeKlass.new("posts"), :b, Post.predicate_builder) - assert_equal "posts", relation.table_name - end - def test_scope_for_create - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) assert_equal({}, relation.scope_for_create) end def test_create_with_value - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) - hash = { hello: "world" } - relation.create_with_value = hash - assert_equal hash, relation.scope_for_create - end - - def test_create_with_value_with_wheres - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) - relation.where! relation.table[:id].eq(10) + relation = Relation.new(Post) relation.create_with_value = { hello: "world" } - assert_equal({ hello: "world", id: 10 }, relation.scope_for_create) + assert_equal({ "hello" => "world" }, relation.scope_for_create) end - # FIXME: is this really wanted or expected behavior? - def test_scope_for_create_is_cached - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) + def test_create_with_value_with_wheres + relation = Relation.new(Post) assert_equal({}, relation.scope_for_create) - relation.where! relation.table[:id].eq(10) - assert_equal({}, relation.scope_for_create) + relation.where!(id: 10) + assert_equal({ "id" => 10 }, relation.scope_for_create) relation.create_with_value = { hello: "world" } - assert_equal({}, relation.scope_for_create) + assert_equal({ "hello" => "world", "id" => 10 }, relation.scope_for_create) + end + + def test_empty_scope + relation = Relation.new(Post) + assert_predicate relation, :empty_scope? + + relation.merge!(relation) + assert_predicate relation, :empty_scope? end def test_bad_constants_raise_errors @@ -132,31 +110,31 @@ module ActiveRecord end def test_empty_eager_loading? - relation = Relation.new(FakeKlass, :b, nil) - assert !relation.eager_loading? + relation = Relation.new(FakeKlass) + assert_not_predicate relation, :eager_loading? end def test_eager_load_values - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) relation.eager_load! :b - assert relation.eager_loading? + assert_predicate relation, :eager_loading? end def test_references_values - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) assert_equal [], relation.references_values relation = relation.references(:foo).references(:omg, :lol) assert_equal ["foo", "omg", "lol"], relation.references_values end def test_references_values_dont_duplicate - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) relation = relation.references(:foo).references(:foo) assert_equal ["foo"], relation.references_values end test "merging a hash into a relation" do - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) + relation = Relation.new(Post) relation = relation.merge where: { name: :lol }, readonly: true assert_equal({ "name" => :lol }, relation.where_clause.to_h) @@ -164,7 +142,7 @@ module ActiveRecord end test "merging an empty hash into a relation" do - assert_equal Relation::WhereClause.empty, Relation.new(FakeKlass, :b, nil).merge({}).where_clause + assert_equal Relation::WhereClause.empty, Relation.new(FakeKlass).merge({}).where_clause end test "merging a hash with unknown keys raises" do @@ -172,7 +150,7 @@ module ActiveRecord end test "merging nil or false raises" do - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) e = assert_raises(ArgumentError) do relation = relation.merge nil @@ -188,7 +166,7 @@ module ActiveRecord end test "#values returns a dup of the values" do - relation = Relation.new(Post, Post.arel_table, Post.predicate_builder).where!(name: :foo) + relation = Relation.new(Post).where!(name: :foo) values = relation.values values[:where] = nil @@ -196,7 +174,7 @@ module ActiveRecord end test "relations can be created with a values hash" do - relation = Relation.new(FakeKlass, :b, nil, select: [:foo]) + relation = Relation.new(FakeKlass, values: { select: [:foo] }) assert_equal [:foo], relation.select_values end @@ -208,13 +186,13 @@ module ActiveRecord end end - relation = Relation.new(klass, :b, nil) + relation = Relation.new(klass) relation.merge!(where: ["foo = ?", "bar"]) - assert_equal Relation::WhereClause.new(["foo = bar"], []), relation.where_clause + assert_equal Relation::WhereClause.new(["foo = bar"]), relation.where_clause end def test_merging_readonly_false - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) readonly_false_relation = relation.readonly(false) # test merging in both directions assert_equal false, relation.merge(readonly_false_relation).readonly_value @@ -224,7 +202,50 @@ module ActiveRecord def test_relation_merging_with_merged_joins_as_symbols special_comments_with_ratings = SpecialComment.joins(:ratings) posts_with_special_comments_with_ratings = Post.group("posts.id").joins(:special_comments).merge(special_comments_with_ratings) - assert_equal({ 2 => 1, 4 => 3, 5 => 1 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count) + assert_equal({ 4 => 2 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count) + end + + def test_relation_merging_with_merged_symbol_joins_keeps_inner_joins + queries = capture_sql { Author.joins(:posts).merge(Post.joins(:comments)).to_a } + + nb_inner_join = queries.sum { |sql| sql.scan(/INNER\s+JOIN/i).size } + assert_equal 2, nb_inner_join, "Wrong amount of INNER JOIN in query" + assert queries.none? { |sql| /LEFT\s+(OUTER)?\s+JOIN/i.match?(sql) }, "Shouldn't have any LEFT JOIN in query" + end + + def test_relation_merging_with_merged_symbol_joins_has_correct_size_and_count + # Has one entry per comment + merged_authors_with_commented_posts_relation = Author.joins(:posts).merge(Post.joins(:comments)) + + post_ids_with_author = Post.joins(:author).pluck(:id) + manual_comments_on_post_that_have_author = Comment.where(post_id: post_ids_with_author).pluck(:id) + + assert_equal manual_comments_on_post_that_have_author.size, merged_authors_with_commented_posts_relation.count + assert_equal manual_comments_on_post_that_have_author.size, merged_authors_with_commented_posts_relation.to_a.size + end + + def test_relation_merging_with_merged_symbol_joins_is_aliased + categorizations_with_authors = Categorization.joins(:author) + queries = capture_sql { Post.joins(:author, :categorizations).merge(Author.select(:id)).merge(categorizations_with_authors).to_a } + + nb_inner_join = queries.sum { |sql| sql.scan(/INNER\s+JOIN/i).size } + assert_equal 3, nb_inner_join, "Wrong amount of INNER JOIN in query" + + # using `\W` as the column separator + assert queries.any? { |sql| %r[INNER\s+JOIN\s+#{Author.quoted_table_name}\s+\Wauthors_categorizations\W]i.match?(sql) }, "Should be aliasing the child INNER JOINs in query" + end + + def test_relation_with_merged_joins_aliased_works + categorizations_with_authors = Categorization.joins(:author) + posts_with_joins_and_merges = Post.joins(:author, :categorizations) + .merge(Author.select(:id)).merge(categorizations_with_authors) + + author_with_posts = Author.joins(:posts).ids + categorizations_with_author = Categorization.joins(:author).ids + posts_with_author_and_categorizations = Post.joins(:categorizations).where(author_id: author_with_posts, categorizations: { id: categorizations_with_author }).ids + + assert_equal posts_with_author_and_categorizations.size, posts_with_joins_and_merges.count + assert_equal posts_with_author_and_categorizations.size, posts_with_joins_and_merges.to_a.size end def test_relation_merging_with_joins_as_join_dependency_pick_proper_parent @@ -239,17 +260,17 @@ module ActiveRecord def test_merge_raises_with_invalid_argument assert_raises ArgumentError do - relation = Relation.new(FakeKlass, :b, nil) + relation = Relation.new(FakeKlass) relation.merge(true) end end def test_respond_to_for_non_selected_element post = Post.select(:title).first - assert_equal false, post.respond_to?(:body), "post should not respond_to?(:body) since invoking it raises exception" + assert_not_respond_to post, :body, "post should not respond_to?(:body) since invoking it raises exception" silence_warnings { post = Post.select("'title' as post_title").first } - assert_equal false, post.respond_to?(:title), "post should not respond_to?(:body) since invoking it raises exception" + assert_not_respond_to post, :title, "post should not respond_to?(:body) since invoking it raises exception" end def test_select_quotes_when_using_from_clause @@ -277,18 +298,32 @@ module ActiveRecord assert_equal({ 2 => 1, 4 => 3, 5 => 1 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count) end + def test_relation_merging_keeps_joining_order + authors = Author.where(id: 1) + posts = Post.joins(:author).merge(authors) + comments = Comment.joins(:post).merge(posts) + ratings = Rating.joins(:comment).merge(comments) + + assert_equal 3, ratings.count + end + class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value def type :string end + def cast(value) + raise value unless value == "value from user" + "cast value" + end + def deserialize(value) raise value unless value == "type cast for database" "type cast from database" end def serialize(value) - raise value unless value == "value from user" + raise value unless value == "cast value" "type cast for database" end end @@ -305,6 +340,14 @@ module ActiveRecord assert_equal "type cast from database", UpdateAllTestModel.first.body end + def test_skip_preloading_after_arel_has_been_generated + assert_nothing_raised do + relation = Comment.all + relation.arel + relation.skip_preloading! + end + end + private def skip_if_sqlite3_version_includes_quoting_bug diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 0c94e891eb..c14dc23cf3 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/tag" require "models/tagging" @@ -7,6 +9,8 @@ require "models/comment" require "models/author" require "models/entrant" require "models/developer" +require "models/project" +require "models/person" require "models/computer" require "models/reply" require "models/company" @@ -15,19 +19,21 @@ require "models/car" require "models/engine" require "models/tyre" require "models/minivan" -require "models/aircraft" require "models/possession" require "models/reader" +require "models/category" require "models/categorization" require "models/edge" +require "models/subscriber" class RelationTest < ActiveRecord::TestCase - fixtures :authors, :topics, :entrants, :developers, :companies, :developers_projects, :accounts, :categories, :categorizations, :posts, :comments, - :tags, :taggings, :cars, :minivans + fixtures :authors, :author_addresses, :topics, :entrants, :developers, :people, :companies, :developers_projects, :accounts, :categories, :categorizations, :categories_posts, :posts, :comments, :tags, :taggings, :cars, :minivans class TopicWithCallbacks < ActiveRecord::Base self.table_name = :topics + cattr_accessor :topic_count before_update { |topic| topic.author_name = "David" if topic.author_name.blank? } + after_update { |topic| topic.class.topic_count = topic.class.count } end def test_do_not_double_quote_string_id @@ -55,7 +61,7 @@ class RelationTest < ActiveRecord::TestCase def test_dynamic_finder x = Post.where("author_id = ?", 1) - assert x.klass.respond_to?(:find_by_id), "@klass should handle dynamic finders" + assert_respond_to x.klass, :find_by_id end def test_multivalue_where @@ -97,7 +103,7 @@ class RelationTest < ActiveRecord::TestCase 2.times { assert_equal 5, topics.to_a.size } end - assert topics.loaded? + assert_predicate topics, :loaded? end def test_scoped_first @@ -107,36 +113,36 @@ class RelationTest < ActiveRecord::TestCase 2.times { assert_equal "The First Topic", topics.first.title } end - assert ! topics.loaded? + assert_not_predicate topics, :loaded? end def test_loaded_first topics = Topic.all.order("id ASC") - topics.to_a # force load + topics.load # force load assert_no_queries do assert_equal "The First Topic", topics.first.title end - assert topics.loaded? + assert_predicate topics, :loaded? end def test_loaded_first_with_limit topics = Topic.all.order("id ASC") - topics.to_a # force load + topics.load # force load assert_no_queries do assert_equal ["The First Topic", "The Second Topic of the day"], topics.first(2).map(&:title) end - assert topics.loaded? + assert_predicate topics, :loaded? end def test_first_get_more_than_available topics = Topic.all.order("id ASC") unloaded_first = topics.first(10) - topics.to_a # force load + topics.load # force load assert_no_queries do loaded_first = topics.first(10) @@ -151,14 +157,14 @@ class RelationTest < ActiveRecord::TestCase 2.times { topics.to_a } end - assert topics.loaded? + assert_predicate topics, :loaded? original_size = topics.to_a.size Topic.create! title: "fake" assert_queries(1) { topics.reload } assert_equal original_size + 1, topics.size - assert topics.loaded? + assert_predicate topics, :loaded? end def test_finding_with_subquery @@ -194,6 +200,18 @@ class RelationTest < ActiveRecord::TestCase assert_equal(relation.map(&:post_count).sort, subquery.values.sort) end + def test_finding_with_subquery_with_eager_loading_in_from + relation = Comment.includes(:post).where("posts.type": "Post") + assert_equal relation.to_a, Comment.select("*").from(relation).to_a + assert_equal relation.to_a, Comment.select("subquery.*").from(relation).to_a + assert_equal relation.to_a, Comment.select("a.*").from(relation, :a).to_a + end + + def test_finding_with_subquery_with_eager_loading_in_where + relation = Comment.includes(:post).where("posts.type": "Post") + assert_equal relation.sort_by(&:id), Comment.where(id: relation).sort_by(&:id) + end + def test_finding_with_conditions assert_equal ["David"], Author.where(name: "David").map(&:name) assert_equal ["Mary"], Author.where(["name = ?", "Mary"]).map(&:name) @@ -218,45 +236,68 @@ class RelationTest < ActiveRecord::TestCase assert_equal topics(:fifth).title, topics.first.title end - def test_finding_with_reverted_assoc_order + def test_finding_with_arel_assoc_order + topics = Topic.order(Arel.sql("id") => :desc) + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_finding_with_reversed_assoc_order topics = Topic.order(id: :asc).reverse_order assert_equal 5, topics.to_a.size assert_equal topics(:fifth).title, topics.first.title end + def test_finding_with_reversed_arel_assoc_order + topics = Topic.order(Arel.sql("id") => :asc).reverse_order + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + def test_reverse_order_with_function - topics = Topic.order("length(title)").reverse_order + topics = Topic.order(Arel.sql("length(title)")).reverse_order + assert_equal topics(:second).title, topics.first.title + end + + def test_reverse_arel_assoc_order_with_function + topics = Topic.order(Arel.sql("length(title)") => :asc).reverse_order assert_equal topics(:second).title, topics.first.title end def test_reverse_order_with_function_other_predicates - topics = Topic.order("author_name, length(title), id").reverse_order + topics = Topic.order(Arel.sql("author_name, length(title), id")).reverse_order assert_equal topics(:second).title, topics.first.title - topics = Topic.order("length(author_name), id, length(title)").reverse_order + topics = Topic.order(Arel.sql("length(author_name), id, length(title)")).reverse_order assert_equal topics(:fifth).title, topics.first.title end def test_reverse_order_with_multiargument_function assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(author_name, title)").reverse_order + Topic.order(Arel.sql("concat(author_name, title)")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(lower(author_name), title)").reverse_order + Topic.order(Arel.sql("concat(lower(author_name), title)")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(author_name, lower(title))").reverse_order + Topic.order(Arel.sql("concat(author_name, lower(title))")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(lower(author_name), title, length(title)").reverse_order + Topic.order(Arel.sql("concat(lower(author_name), title, length(title)")).reverse_order + end + end + + def test_reverse_arel_assoc_order_with_multiargument_function + assert_nothing_raised do + Topic.order(Arel.sql("REPLACE(title, '', '')") => :asc).reverse_order end end def test_reverse_order_with_nulls_first_or_last assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("title NULLS FIRST").reverse_order + Topic.order(Arel.sql("title NULLS FIRST")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("title nulls last").reverse_order + Topic.order(Arel.sql("title nulls last")).reverse_order end end @@ -349,29 +390,29 @@ class RelationTest < ActiveRecord::TestCase def test_finding_with_cross_table_order_and_limit tags = Tag.includes(:taggings). - order("tags.name asc", "taggings.taggable_id asc", "REPLACE('abc', taggings.taggable_type, taggings.taggable_type)"). + order("tags.name asc", "taggings.taggable_id asc", Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")). limit(1).to_a assert_equal 1, tags.length end def test_finding_with_complex_order_and_limit - tags = Tag.includes(:taggings).references(:taggings).order("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)").limit(1).to_a + tags = Tag.includes(:taggings).references(:taggings).order(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).limit(1).to_a assert_equal 1, tags.length end def test_finding_with_complex_order - tags = Tag.includes(:taggings).references(:taggings).order("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)").to_a + tags = Tag.includes(:taggings).references(:taggings).order(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).to_a assert_equal 3, tags.length end def test_finding_with_sanitized_order - query = Tag.order(["field(id, ?)", [1, 3, 2]]).to_sql + query = Tag.order([Arel.sql("field(id, ?)"), [1, 3, 2]]).to_sql assert_match(/field\(id, 1,3,2\)/, query) - query = Tag.order(["field(id, ?)", []]).to_sql + query = Tag.order([Arel.sql("field(id, ?)"), []]).to_sql assert_match(/field\(id, NULL\)/, query) - query = Tag.order(["field(id, ?)", nil]).to_sql + query = Tag.order([Arel.sql("field(id, ?)"), nil]).to_sql assert_match(/field\(id, NULL\)/, query) end @@ -397,123 +438,6 @@ class RelationTest < ActiveRecord::TestCase assert_equal [2, 4, 6, 8, 10], even_ids.sort end - def test_none - assert_no_queries(ignore_none: false) do - assert_equal [], Developer.none - assert_equal [], Developer.all.none - end - end - - def test_none_chainable - assert_no_queries(ignore_none: false) do - assert_equal [], Developer.none.where(name: "David") - end - end - - def test_none_chainable_to_existing_scope_extension_method - assert_no_queries(ignore_none: false) do - assert_equal 1, Topic.anonymous_extension.none.one - end - end - - def test_none_chained_to_methods_firing_queries_straight_to_db - assert_no_queries(ignore_none: false) do - assert_equal [], Developer.none.pluck(:id, :name) - assert_equal 0, Developer.none.delete_all - assert_equal 0, Developer.none.update_all(name: "David") - assert_equal 0, Developer.none.delete(1) - assert_equal false, Developer.none.exists?(1) - end - end - - def test_null_relation_content_size_methods - assert_no_queries(ignore_none: false) do - assert_equal 0, Developer.none.size - assert_equal 0, Developer.none.count - assert_equal true, Developer.none.empty? - assert_equal true, Developer.none.none? - assert_equal false, Developer.none.any? - assert_equal false, Developer.none.one? - assert_equal false, Developer.none.many? - end - end - - def test_null_relation_calculations_methods - assert_no_queries(ignore_none: false) do - assert_equal 0, Developer.none.count - assert_equal 0, Developer.none.calculate(:count, nil) - assert_nil Developer.none.calculate(:average, "salary") - end - end - - def test_null_relation_metadata_methods - assert_equal "", Developer.none.to_sql - assert_equal({}, Developer.none.where_values_hash) - end - - def test_null_relation_where_values_hash - assert_equal({ "salary" => 100_000 }, Developer.none.where(salary: 100_000).where_values_hash) - end - - def test_null_relation_sum - ac = Aircraft.new - assert_equal Hash.new, ac.engines.group(:id).sum(:id) - assert_equal 0, ac.engines.count - ac.save - assert_equal Hash.new, ac.engines.group(:id).sum(:id) - assert_equal 0, ac.engines.count - end - - def test_null_relation_count - ac = Aircraft.new - assert_equal Hash.new, ac.engines.group(:id).count - assert_equal 0, ac.engines.count - ac.save - assert_equal Hash.new, ac.engines.group(:id).count - assert_equal 0, ac.engines.count - end - - def test_null_relation_size - ac = Aircraft.new - assert_equal Hash.new, ac.engines.group(:id).size - assert_equal 0, ac.engines.size - ac.save - assert_equal Hash.new, ac.engines.group(:id).size - assert_equal 0, ac.engines.size - end - - def test_null_relation_average - ac = Aircraft.new - assert_equal Hash.new, ac.engines.group(:car_id).average(:id) - assert_nil ac.engines.average(:id) - ac.save - assert_equal Hash.new, ac.engines.group(:car_id).average(:id) - assert_nil ac.engines.average(:id) - end - - def test_null_relation_minimum - ac = Aircraft.new - assert_equal Hash.new, ac.engines.group(:car_id).minimum(:id) - assert_nil ac.engines.minimum(:id) - ac.save - assert_equal Hash.new, ac.engines.group(:car_id).minimum(:id) - assert_nil ac.engines.minimum(:id) - end - - def test_null_relation_maximum - ac = Aircraft.new - assert_equal Hash.new, ac.engines.group(:car_id).maximum(:id) - assert_nil ac.engines.maximum(:id) - ac.save - assert_equal Hash.new, ac.engines.group(:car_id).maximum(:id) - assert_nil ac.engines.maximum(:id) - end - - def test_null_relation_in_where_condition - assert_operator Comment.count, :>, 0 # precondition, make sure there are comments. - assert_equal 0, Comment.where(post_id: Post.none).to_a.size - end - def test_joins_with_nil_argument assert_nothing_raised { DependentFirm.joins(nil).first } end @@ -563,15 +487,7 @@ class RelationTest < ActiveRecord::TestCase assert_nothing_raised { Topic.reorder([]) } end - def test_scoped_responds_to_delegated_methods - relation = Topic.all - - ["map", "uniq", "sort", "insert", "delete", "update"].each do |method| - assert_respond_to relation, method, "Topic.all should respond to #{method.inspect}" - end - end - - def test_respond_to_delegates_to_relation + def test_respond_to_delegates_to_arel relation = Topic.all fake_arel = Struct.new(:responds) { def respond_to?(method, access = false) @@ -584,26 +500,22 @@ class RelationTest < ActiveRecord::TestCase relation.respond_to?(:matching_attributes) assert_equal [:matching_attributes, false], fake_arel.responds.first - - fake_arel.responds = [] - relation.respond_to?(:matching_attributes, true) - assert_equal [:matching_attributes, true], fake_arel.responds.first end def test_respond_to_dynamic_finders relation = Topic.all ["find_by_title", "find_by_title_and_author_name"].each do |method| - assert_respond_to relation, method, "Topic.all should respond to #{method.inspect}" + assert_respond_to relation, method end end def test_respond_to_class_methods_and_scopes - assert Topic.all.respond_to?(:by_lifo) + assert_respond_to Topic.all, :by_lifo end def test_find_with_readonly_option - Developer.all.each { |d| assert !d.readonly? } + Developer.all.each { |d| assert_not d.readonly? } Developer.all.readonly.each { |d| assert d.readonly? } end @@ -676,16 +588,6 @@ class RelationTest < ActiveRecord::TestCase end end - def test_default_scope_with_conditions_string - assert_equal Developer.where(name: "David").map(&:id).sort, DeveloperCalledDavid.all.map(&:id).sort - assert_nil DeveloperCalledDavid.create!.name - end - - def test_default_scope_with_conditions_hash - assert_equal Developer.where(name: "Jamis").map(&:id).sort, DeveloperCalledJamis.all.map(&:id).sort - assert_equal "Jamis", DeveloperCalledJamis.create!.name - end - def test_default_scoping_finder_methods developers = DeveloperCalledDavid.order("id").map(&:id).sort assert_equal Developer.where(name: "David").map(&:id).sort, developers @@ -704,7 +606,7 @@ class RelationTest < ActiveRecord::TestCase reader = Reader.create! post_id: post.id, person_id: 1 comment = Comment.create! post_id: post.id, body: "body" - assert !comment.respond_to?(:readers) + assert_not_respond_to comment, :readers post_rel = Post.preload(:readers).joins(:readers).where(title: "Uhuu") result_comment = Comment.joins(:post).merge(post_rel).to_a.first @@ -825,7 +727,7 @@ class RelationTest < ActiveRecord::TestCase def test_find_in_empty_array authors = Author.all.where(id: []) - assert authors.to_a.blank? + assert_predicate authors.to_a, :blank? end def test_where_with_ar_object @@ -883,8 +785,6 @@ class RelationTest < ActiveRecord::TestCase def test_find_all_using_where_with_relation david = authors(:david) - # switching the lines below would succeed in current rails - # assert_queries(2) { assert_queries(1) { relation = Author.where(id: Author.where(id: david.id)) assert_equal [david], relation.to_a @@ -923,8 +823,6 @@ class RelationTest < ActiveRecord::TestCase def test_find_all_using_where_with_relation_and_alternate_primary_key cool_first = minivans(:cool_first) - # switching the lines below would succeed in current rails - # assert_queries(2) { assert_queries(1) { relation = Minivan.where(minivan_id: Minivan.where(name: cool_first.name)) assert_equal [cool_first], relation.to_a @@ -960,32 +858,6 @@ class RelationTest < ActiveRecord::TestCase } end - def test_exists - davids = Author.where(name: "David") - assert davids.exists? - assert davids.exists?(authors(:david).id) - assert ! davids.exists?(authors(:mary).id) - assert ! davids.exists?("42") - assert ! davids.exists?(42) - assert ! davids.exists?(davids.new.id) - - fake = Author.where(name: "fake author") - assert ! fake.exists? - assert ! fake.exists?(authors(:david).id) - end - - def test_exists_uses_existing_scope - post = authors(:david).posts.first - authors = Author.includes(:posts).where(name: "David", posts: { id: post.id }) - assert authors.exists?(authors(:david).id) - end - - def test_any_with_scope_on_hash_includes - post = authors(:david).posts.first - categories = Categorization.includes(author: :posts).where(posts: { id: post.id }) - assert categories.exists? - end - def test_last authors = Author.all assert_equal authors(:bob), authors.last @@ -996,19 +868,19 @@ class RelationTest < ActiveRecord::TestCase # Force load assert_equal [authors(:david)], davids.to_a - assert davids.loaded? + assert_predicate davids, :loaded? assert_difference("Author.count", -1) { davids.destroy_all } assert_equal [], davids.to_a - assert davids.loaded? + assert_predicate davids, :loaded? end def test_delete_all davids = Author.where(name: "David") assert_difference("Author.count", -1) { davids.delete_all } - assert ! davids.loaded? + assert_not_predicate davids, :loaded? end def test_delete_all_loaded @@ -1016,20 +888,18 @@ class RelationTest < ActiveRecord::TestCase # Force load assert_equal [authors(:david)], davids.to_a - assert davids.loaded? + assert_predicate davids, :loaded? assert_difference("Author.count", -1) { davids.delete_all } assert_equal [], davids.to_a - assert davids.loaded? + assert_predicate davids, :loaded? end def test_delete_all_with_unpermitted_relation_raises_error - assert_raises(ActiveRecord::ActiveRecordError) { Author.limit(10).delete_all } assert_raises(ActiveRecord::ActiveRecordError) { Author.distinct.delete_all } assert_raises(ActiveRecord::ActiveRecordError) { Author.group(:name).delete_all } assert_raises(ActiveRecord::ActiveRecordError) { Author.having("SUM(id) < 3").delete_all } - assert_raises(ActiveRecord::ActiveRecordError) { Author.offset(10).delete_all } end def test_select_with_aggregates @@ -1037,9 +907,9 @@ class RelationTest < ActiveRecord::TestCase assert_equal 11, posts.count(:all) assert_equal 11, posts.size - assert posts.any? - assert posts.many? - assert_not posts.empty? + assert_predicate posts, :any? + assert_predicate posts, :many? + assert_not_empty posts end def test_select_takes_a_variable_list_of_args @@ -1068,13 +938,13 @@ class RelationTest < ActiveRecord::TestCase assert_equal 11, posts.count(:all) assert_equal 11, posts.count(:id) - assert_equal 1, posts.where("comments_count > 1").count - assert_equal 9, posts.where(comments_count: 0).count + assert_equal 3, posts.where("comments_count > 1").count + assert_equal 6, posts.where(comments_count: 0).count end def test_count_with_block posts = Post.all - assert_equal 10, posts.count { |p| p.comments_count.even? } + assert_equal 8, posts.count { |p| p.comments_count.even? } end def test_count_on_association_relation @@ -1085,19 +955,37 @@ class RelationTest < ActiveRecord::TestCase assert_equal author.posts.where(author_id: author.id).size, posts.count assert_equal 0, author.posts.where(author_id: another_author.id).size - assert author.posts.where(author_id: another_author.id).empty? + assert_empty author.posts.where(author_id: another_author.id) end def test_count_with_distinct posts = Post.all - assert_equal 3, posts.distinct(true).count(:comments_count) + assert_equal 4, posts.distinct(true).count(:comments_count) assert_equal 11, posts.distinct(false).count(:comments_count) - assert_equal 3, posts.distinct(true).select(:comments_count).count + assert_equal 4, posts.distinct(true).select(:comments_count).count assert_equal 11, posts.distinct(false).select(:comments_count).count end + def test_size_with_distinct + posts = Post.distinct.select(:author_id, :comments_count) + assert_queries(1) { assert_equal 8, posts.size } + assert_queries(1) { assert_equal 8, posts.load.size } + end + + def test_size_with_eager_loading_and_custom_order + posts = Post.includes(:comments).order("comments.id") + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + end + + def test_size_with_eager_loading_and_custom_order_and_distinct + posts = Post.includes(:comments).order("comments.id").distinct + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + end + def test_update_all_with_scope tag = Tag.first Post.tagged_with(tag.id).update_all title: "rofl" @@ -1129,31 +1017,31 @@ class RelationTest < ActiveRecord::TestCase posts = Post.all assert_queries(1) { assert_equal 11, posts.size } - assert ! posts.loaded? + assert_not_predicate posts, :loaded? best_posts = posts.where(comments_count: 0) - best_posts.to_a # force load - assert_no_queries { assert_equal 9, best_posts.size } + best_posts.load # force load + assert_no_queries { assert_equal 6, best_posts.size } end def test_size_with_limit posts = Post.limit(10) assert_queries(1) { assert_equal 10, posts.size } - assert ! posts.loaded? + assert_not_predicate posts, :loaded? best_posts = posts.where(comments_count: 0) - best_posts.to_a # force load - assert_no_queries { assert_equal 9, best_posts.size } + best_posts.load # force load + assert_no_queries { assert_equal 6, best_posts.size } end def test_size_with_zero_limit posts = Post.limit(0) assert_no_queries { assert_equal 0, posts.size } - assert ! posts.loaded? + assert_not_predicate posts, :loaded? - posts.to_a # force load + posts.load # force load assert_no_queries { assert_equal 0, posts.size } end @@ -1161,13 +1049,13 @@ class RelationTest < ActiveRecord::TestCase posts = Post.limit(0) assert_no_queries { assert_equal true, posts.empty? } - assert ! posts.loaded? + assert_not_predicate posts, :loaded? end def test_count_complex_chained_relations posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count > 0") - expected = { 1 => 2 } + expected = { 1 => 4, 2 => 1 } assert_equal expected, posts.count end @@ -1175,14 +1063,14 @@ class RelationTest < ActiveRecord::TestCase posts = Post.all assert_queries(1) { assert_equal false, posts.empty? } - assert ! posts.loaded? + assert_not_predicate posts, :loaded? no_posts = posts.where(title: "") assert_queries(1) { assert_equal true, no_posts.empty? } - assert ! no_posts.loaded? + assert_not_predicate no_posts, :loaded? best_posts = posts.where(comments_count: 0) - best_posts.to_a # force load + best_posts.load # force load assert_no_queries { assert_equal false, best_posts.empty? } end @@ -1190,11 +1078,11 @@ class RelationTest < ActiveRecord::TestCase posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count > 0") assert_queries(1) { assert_equal false, posts.empty? } - assert ! posts.loaded? + assert_not_predicate posts, :loaded? no_posts = posts.where(title: "") assert_queries(1) { assert_equal true, no_posts.empty? } - assert ! no_posts.loaded? + assert_not_predicate no_posts, :loaded? end def test_any @@ -1210,13 +1098,13 @@ class RelationTest < ActiveRecord::TestCase assert_queries(3) do assert posts.any? # Uses COUNT() - assert ! posts.where(id: nil).any? + assert_not_predicate posts.where(id: nil), :any? assert posts.any? { |p| p.id > 0 } - assert ! posts.any? { |p| p.id <= 0 } + assert_not posts.any? { |p| p.id <= 0 } end - assert posts.loaded? + assert_predicate posts, :loaded? end def test_many @@ -1225,49 +1113,49 @@ class RelationTest < ActiveRecord::TestCase assert_queries(2) do assert posts.many? # Uses COUNT() assert posts.many? { |p| p.id > 0 } - assert ! posts.many? { |p| p.id < 2 } + assert_not posts.many? { |p| p.id < 2 } end - assert posts.loaded? + assert_predicate posts, :loaded? end def test_many_with_limits posts = Post.all - assert posts.many? - assert ! posts.limit(1).many? + assert_predicate posts, :many? + assert_not_predicate posts.limit(1), :many? end def test_none? posts = Post.all assert_queries(1) do - assert ! posts.none? # Uses COUNT() + assert_not posts.none? # Uses COUNT() end - assert ! posts.loaded? + assert_not_predicate posts, :loaded? assert_queries(1) do assert posts.none? { |p| p.id < 0 } - assert ! posts.none? { |p| p.id == 1 } + assert_not posts.none? { |p| p.id == 1 } end - assert posts.loaded? + assert_predicate posts, :loaded? end def test_one posts = Post.all assert_queries(1) do - assert ! posts.one? # Uses COUNT() + assert_not posts.one? # Uses COUNT() end - assert ! posts.loaded? + assert_not_predicate posts, :loaded? assert_queries(1) do - assert ! posts.one? { |p| p.id < 3 } + assert_not posts.one? { |p| p.id < 3 } assert posts.one? { |p| p.id == 1 } end - assert posts.loaded? + assert_predicate posts, :loaded? end def test_to_a_should_dup_target @@ -1300,10 +1188,10 @@ class RelationTest < ActiveRecord::TestCase sparrow = birds.create assert_kind_of Bird, sparrow - assert !sparrow.persisted? + assert_not_predicate sparrow, :persisted? hen = birds.where(name: "hen").create - assert hen.persisted? + assert_predicate hen, :persisted? assert_equal "hen", hen.name end @@ -1314,34 +1202,43 @@ class RelationTest < ActiveRecord::TestCase hen = birds.where(name: "hen").create! assert_kind_of Bird, hen - assert hen.persisted? + assert_predicate hen, :persisted? assert_equal "hen", hen.name end + def test_create_with_polymorphic_association + author = authors(:david) + post = posts(:welcome) + comment = Comment.where(post: post, author: author).create!(body: "hello") + + assert_equal author, comment.author + assert_equal post, comment.post + end + def test_first_or_create parrot = Bird.where(color: "green").first_or_create(name: "parrot") assert_kind_of Bird, parrot - assert parrot.persisted? + assert_predicate parrot, :persisted? assert_equal "parrot", parrot.name assert_equal "green", parrot.color same_parrot = Bird.where(color: "green").first_or_create(name: "parakeet") assert_kind_of Bird, same_parrot - assert same_parrot.persisted? + assert_predicate same_parrot, :persisted? assert_equal parrot, same_parrot end def test_first_or_create_with_no_parameters parrot = Bird.where(color: "green").first_or_create assert_kind_of Bird, parrot - assert !parrot.persisted? + assert_not_predicate parrot, :persisted? assert_equal "green", parrot.color end def test_first_or_create_with_block parrot = Bird.where(color: "green").first_or_create { |bird| bird.name = "parrot" } assert_kind_of Bird, parrot - assert parrot.persisted? + assert_predicate parrot, :persisted? assert_equal "green", parrot.color assert_equal "parrot", parrot.name @@ -1362,13 +1259,13 @@ class RelationTest < ActiveRecord::TestCase def test_first_or_create_bang_with_valid_options parrot = Bird.where(color: "green").first_or_create!(name: "parrot") assert_kind_of Bird, parrot - assert parrot.persisted? + assert_predicate parrot, :persisted? assert_equal "parrot", parrot.name assert_equal "green", parrot.color same_parrot = Bird.where(color: "green").first_or_create!(name: "parakeet") assert_kind_of Bird, same_parrot - assert same_parrot.persisted? + assert_predicate same_parrot, :persisted? assert_equal parrot, same_parrot end @@ -1383,7 +1280,7 @@ class RelationTest < ActiveRecord::TestCase def test_first_or_create_bang_with_valid_block parrot = Bird.where(color: "green").first_or_create! { |bird| bird.name = "parrot" } assert_kind_of Bird, parrot - assert parrot.persisted? + assert_predicate parrot, :persisted? assert_equal "green", parrot.color assert_equal "parrot", parrot.name @@ -1414,9 +1311,9 @@ class RelationTest < ActiveRecord::TestCase def test_first_or_initialize parrot = Bird.where(color: "green").first_or_initialize(name: "parrot") assert_kind_of Bird, parrot - assert !parrot.persisted? - assert parrot.valid? - assert parrot.new_record? + assert_not_predicate parrot, :persisted? + assert_predicate parrot, :valid? + assert_predicate parrot, :new_record? assert_equal "parrot", parrot.name assert_equal "green", parrot.color end @@ -1424,18 +1321,18 @@ class RelationTest < ActiveRecord::TestCase def test_first_or_initialize_with_no_parameters parrot = Bird.where(color: "green").first_or_initialize assert_kind_of Bird, parrot - assert !parrot.persisted? - assert !parrot.valid? - assert parrot.new_record? + assert_not_predicate parrot, :persisted? + assert_not_predicate parrot, :valid? + assert_predicate parrot, :new_record? assert_equal "green", parrot.color end def test_first_or_initialize_with_block parrot = Bird.where(color: "green").first_or_initialize { |bird| bird.name = "parrot" } assert_kind_of Bird, parrot - assert !parrot.persisted? - assert parrot.valid? - assert parrot.new_record? + assert_not_predicate parrot, :persisted? + assert_predicate parrot, :valid? + assert_predicate parrot, :new_record? assert_equal "green", parrot.color assert_equal "parrot", parrot.name end @@ -1444,7 +1341,7 @@ class RelationTest < ActiveRecord::TestCase assert_nil Bird.find_by(name: "bob") bird = Bird.find_or_create_by(name: "bob") - assert bird.persisted? + assert_predicate bird, :persisted? assert_equal bird, Bird.find_or_create_by(name: "bob") end @@ -1453,7 +1350,7 @@ class RelationTest < ActiveRecord::TestCase assert_nil Bird.find_by(name: "bob") bird = Bird.create_with(color: "green").find_or_create_by(name: "bob") - assert bird.persisted? + assert_predicate bird, :persisted? assert_equal "green", bird.color assert_equal bird, Bird.create_with(color: "blue").find_or_create_by(name: "bob") @@ -1463,17 +1360,45 @@ class RelationTest < ActiveRecord::TestCase assert_raises(ActiveRecord::RecordInvalid) { Bird.find_or_create_by!(color: "green") } end + def test_create_or_find_by + assert_nil Subscriber.find_by(nick: "bob") + + subscriber = Subscriber.create!(nick: "bob") + + assert_equal subscriber, Subscriber.create_or_find_by(nick: "bob") + assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat") + end + + def test_create_or_find_by_with_non_unique_attributes + Subscriber.create!(nick: "bob", name: "the builder") + + assert_raises(ActiveRecord::RecordNotFound) do + Subscriber.create_or_find_by(nick: "bob", name: "the cat") + end + end + + def test_create_or_find_by_within_transaction + assert_nil Subscriber.find_by(nick: "bob") + + subscriber = Subscriber.create!(nick: "bob") + + Subscriber.transaction do + assert_equal subscriber, Subscriber.create_or_find_by(nick: "bob") + assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat") + end + end + def test_find_or_initialize_by assert_nil Bird.find_by(name: "bob") bird = Bird.find_or_initialize_by(name: "bob") - assert bird.new_record? + assert_predicate bird, :new_record? bird.save! assert_equal bird, Bird.find_or_initialize_by(name: "bob") end - def test_explicit_create_scope + def test_explicit_create_with hens = Bird.where(name: "hen") assert_equal "hen", hens.new.name @@ -1481,6 +1406,16 @@ class RelationTest < ActiveRecord::TestCase assert_equal "cock", hens.new.name end + def test_create_with_nested_attributes + assert_difference("Project.count", 1) do + developers = Developer.where(name: "Aaron") + developers = developers.create_with( + projects_attributes: [{ name: "p1" }] + ) + developers.create! + end + end + def test_except relation = Post.where(author_id: 1).order("id ASC").limit(1) assert_equal [posts(:welcome)], relation.to_a @@ -1604,12 +1539,76 @@ class RelationTest < ActiveRecord::TestCase assert_equal posts(:welcome), comments(:greetings).post end + def test_touch_all_updates_records_timestamps + david = developers(:david) + david_previously_updated_at = david.updated_at + jamis = developers(:jamis) + jamis_previously_updated_at = jamis.updated_at + Developer.where(name: "David").touch_all + + assert_not_equal david_previously_updated_at, david.reload.updated_at + assert_equal jamis_previously_updated_at, jamis.reload.updated_at + end + + def test_touch_all_with_custom_timestamp + developer = developers(:david) + previously_created_at = developer.created_at + previously_updated_at = developer.updated_at + Developer.where(name: "David").touch_all(:created_at) + developer = developer.reload + + assert_not_equal previously_created_at, developer.created_at + assert_not_equal previously_updated_at, developer.updated_at + end + + def test_touch_all_with_given_time + developer = developers(:david) + previously_created_at = developer.created_at + previously_updated_at = developer.updated_at + new_time = Time.utc(2015, 2, 16, 4, 54, 0) + Developer.where(name: "David").touch_all(:created_at, time: new_time) + developer = developer.reload + + assert_not_equal previously_created_at, developer.created_at + assert_not_equal previously_updated_at, developer.updated_at + assert_equal new_time, developer.created_at + assert_equal new_time, developer.updated_at + end + + def test_touch_all_updates_locking_column + person = people(:david) + + assert_difference -> { person.reload.lock_version }, +1 do + Person.where(first_name: "David").touch_all + end + end + def test_update_on_relation topic1 = TopicWithCallbacks.create! title: "arel", author_name: nil topic2 = TopicWithCallbacks.create! title: "activerecord", author_name: nil topics = TopicWithCallbacks.where(id: [topic1.id, topic2.id]) topics.update(title: "adequaterecord") + assert_equal TopicWithCallbacks.count, TopicWithCallbacks.topic_count + + assert_equal "adequaterecord", topic1.reload.title + assert_equal "adequaterecord", topic2.reload.title + # Testing that the before_update callbacks have run + assert_equal "David", topic1.reload.author_name + assert_equal "David", topic2.reload.author_name + end + + def test_update_with_ids_on_relation + topic1 = TopicWithCallbacks.create!(title: "arel", author_name: nil) + topic2 = TopicWithCallbacks.create!(title: "activerecord", author_name: nil) + topics = TopicWithCallbacks.none + topics.update( + [topic1.id, topic2.id], + [{ title: "adequaterecord" }, { title: "adequaterecord" }] + ) + + assert_equal TopicWithCallbacks.count, TopicWithCallbacks.topic_count + assert_equal "adequaterecord", topic1.reload.title assert_equal "adequaterecord", topic2.reload.title # Testing that the before_update callbacks have run @@ -1642,10 +1641,10 @@ class RelationTest < ActiveRecord::TestCase def test_doesnt_add_having_values_if_options_are_blank scope = Post.having("") - assert scope.having_clause.empty? + assert_empty scope.having_clause scope = Post.having([]) - assert scope.having_clause.empty? + assert_empty scope.having_clause end def test_having_with_binds_for_both_where_and_having @@ -1671,13 +1670,13 @@ class RelationTest < ActiveRecord::TestCase def test_references_triggers_eager_loading scope = Post.includes(:comments) - assert !scope.eager_loading? - assert scope.references(:comments).eager_loading? + assert_not_predicate scope, :eager_loading? + assert_predicate scope.references(:comments), :eager_loading? end def test_references_doesnt_trigger_eager_loading_if_reference_not_included scope = Post.references(:comments) - assert !scope.eager_loading? + assert_not_predicate scope, :eager_loading? end def test_automatically_added_where_references @@ -1708,6 +1707,13 @@ class RelationTest < ActiveRecord::TestCase scope = Post.order("comments.body") assert_equal ["comments"], scope.references_values + scope = Post.order(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")) + if current_adapter?(:OracleAdapter) + assert_equal ["COMMENTS"], scope.references_values + else + assert_equal ["comments"], scope.references_values + end + scope = Post.order("comments.body", "yaks.body") assert_equal ["comments", "yaks"], scope.references_values @@ -1718,7 +1724,7 @@ class RelationTest < ActiveRecord::TestCase scope = Post.order("comments.body asc") assert_equal ["comments"], scope.references_values - scope = Post.order("foo(comments.body)") + scope = Post.order(Arel.sql("foo(comments.body)")) assert_equal [], scope.references_values end @@ -1726,6 +1732,13 @@ class RelationTest < ActiveRecord::TestCase scope = Post.reorder("comments.body") assert_equal %w(comments), scope.references_values + scope = Post.reorder(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")) + if current_adapter?(:OracleAdapter) + assert_equal ["COMMENTS"], scope.references_values + else + assert_equal ["comments"], scope.references_values + end + scope = Post.reorder("comments.body", "yaks.body") assert_equal %w(comments yaks), scope.references_values @@ -1736,7 +1749,7 @@ class RelationTest < ActiveRecord::TestCase scope = Post.reorder("comments.body asc") assert_equal %w(comments), scope.references_values - scope = Post.reorder("foo(comments.body)") + scope = Post.reorder(Arel.sql("foo(comments.body)")) assert_equal [], scope.references_values end @@ -1761,7 +1774,7 @@ class RelationTest < ActiveRecord::TestCase # checking if there are topics is used before you actually display them, # thus it shouldn't invoke an extra count query. assert_no_queries { assert topics.present? } - assert_no_queries { assert !topics.blank? } + assert_no_queries { assert_not topics.blank? } # shows count of topics and loops after loading the query should not trigger extra queries either. assert_no_queries { topics.size } @@ -1771,7 +1784,7 @@ class RelationTest < ActiveRecord::TestCase # count always trigger the COUNT query. assert_queries(1) { topics.count } - assert topics.loaded? + assert_predicate topics, :loaded? end test "find_by with hash conditions returns the first matching record" do @@ -1862,7 +1875,7 @@ class RelationTest < ActiveRecord::TestCase test "relations with cached arel can't be mutated [internal API]" do relation = Post.all - relation.count + relation.arel assert_raises(ActiveRecord::ImmutableRelation) { relation.limit!(5) } assert_raises(ActiveRecord::ImmutableRelation) { relation.where!("1 = 2") } @@ -1878,6 +1891,12 @@ class RelationTest < ActiveRecord::TestCase assert_equal "#<ActiveRecord::Relation [#{Post.limit(10).map(&:inspect).join(', ')}, ...]>", relation.inspect end + test "relations don't load all records in #inspect" do + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + Post.all.inspect + end + end + test "already-loaded relations don't perform a new query in #inspect" do relation = Post.limit(2) relation.to_a @@ -1890,15 +1909,23 @@ class RelationTest < ActiveRecord::TestCase end test "using a custom table affects the wheres" do - table_alias = Post.arel_table.alias("omg_posts") + post = posts(:welcome) + + assert_equal post, custom_post_relation.where!(title: post.title).take + end + + test "using a custom table with joins affects the joins" do + post = posts(:welcome) - table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias) - predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) - relation = ActiveRecord::Relation.new(Post, table_alias, predicate_builder) - relation.where!(foo: "bar") + assert_equal post, custom_post_relation.joins(:author).where!(title: post.title).take + end + + test "arel_attribute respects a custom table" do + assert_equal [posts(:sti_comments)], custom_post_relation.ranked_by_comments.limit_by(1).to_a + end - node = relation.arel.constraints.first.grep(Arel::Attributes::Attribute).first - assert_equal table_alias, node.relation + test "alias_tracker respects a custom table" do + assert_equal posts(:welcome), custom_post_relation("categories_posts").joins(:categories).first end test "#load" do @@ -1932,7 +1959,7 @@ class RelationTest < ActiveRecord::TestCase test "delegations do not leak to other classes" do Topic.all.by_lifo assert Topic.all.class.method_defined?(:by_lifo) - assert !Post.all.respond_to?(:by_lifo) + assert_not_respond_to Post.all, :by_lifo end def test_unscope_with_subquery @@ -1947,61 +1974,89 @@ class RelationTest < ActiveRecord::TestCase assert_equal p2.first.comments, comments end - def test_unscope_removes_binds - left = Post.where(id: Arel::Nodes::BindParam.new) - column = Post.columns_hash["id"] - left.bind_values += [[column, 20]] + def test_unscope_specific_where_value + posts = Post.where(title: "Welcome to the weblog", body: "Such a lovely day") - relation = left.unscope(where: :id) - assert_equal [], relation.bind_values + assert_equal 1, posts.count + assert_equal 1, posts.unscope(where: :title).count + assert_equal 1, posts.unscope(where: :body).count end - def test_merging_removes_rhs_bind_parameters - left = Post.where(id: 20) - right = Post.where(id: [1, 2, 3, 4]) + def test_locked_should_not_build_arel + posts = Post.locked + assert_predicate posts, :locked? + assert_nothing_raised { posts.lock!(false) } + end - merged = left.merge(right) - assert_equal [], merged.bind_values + def test_relation_join_method + assert_equal "Thank you for the welcome,Thank you again for the welcome", Post.first.comments.join(",") end - def test_merging_keeps_lhs_bind_parameters - binds = [ActiveRecord::Relation::QueryAttribute.new("id", 20, Post.type_for_attribute("id"))] + test "#skip_query_cache!" do + Post.cache do + assert_queries(1) do + Post.all.load + Post.all.load + end + + assert_queries(2) do + Post.all.skip_query_cache!.load + Post.all.skip_query_cache!.load + end + end + end - right = Post.where(id: 20) - left = Post.where(id: 10) + test "#skip_query_cache! with an eager load" do + Post.cache do + assert_queries(1) do + Post.eager_load(:comments).load + Post.eager_load(:comments).load + end - merged = left.merge(right) - assert_equal binds, merged.bound_attributes + assert_queries(2) do + Post.eager_load(:comments).skip_query_cache!.load + Post.eager_load(:comments).skip_query_cache!.load + end + end end - def test_merging_reorders_bind_params - post = Post.first - right = Post.where(id: post.id) - left = Post.where(title: post.title) + test "#skip_query_cache! with a preload" do + Post.cache do + assert_queries(2) do + Post.preload(:comments).load + Post.preload(:comments).load + end - merged = left.merge(right) - assert_equal post, merged.first + assert_queries(4) do + Post.preload(:comments).skip_query_cache!.load + Post.preload(:comments).skip_query_cache!.load + end + end end - def test_relation_join_method - assert_equal "Thank you for the welcome,Thank you again for the welcome", Post.first.comments.join(",") + test "#where with set" do + david = authors(:david) + mary = authors(:mary) + + authors = Author.where(name: ["David", "Mary"].to_set) + assert_equal [david, mary], authors end - def test_connection_adapters_can_reorder_binds - posts = Post.limit(1).offset(2) + test "#where with empty set" do + authors = Author.where(name: Set.new) + assert_empty authors + end - stubbed_connection = Post.connection.dup - def stubbed_connection.combine_bind_parameters(**kwargs) - offset = kwargs[:offset] - kwargs[:offset] = kwargs[:limit] - kwargs[:limit] = offset - super(**kwargs) - end + private + def custom_post_relation(alias_name = "omg_posts") + table_alias = Post.arel_table.alias(alias_name) + table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias) + predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) - posts.define_singleton_method(:connection) do - stubbed_connection + ActiveRecord::Relation.create( + Post, + table: table_alias, + predicate_builder: predicate_builder + ) end - - assert_equal 2, posts.to_a.length - end end diff --git a/activerecord/test/cases/reload_models_test.rb b/activerecord/test/cases/reload_models_test.rb index 5dc9d6d8b7..72f4bfaf6d 100644 --- a/activerecord/test/cases/reload_models_test.rb +++ b/activerecord/test/cases/reload_models_test.rb @@ -1,8 +1,12 @@ +# frozen_string_literal: true + require "cases/helper" require "models/owner" require "models/pet" class ReloadModelsTest < ActiveRecord::TestCase + include ActiveSupport::Testing::Isolation + fixtures :pets, :owners def test_has_one_with_reload @@ -13,10 +17,10 @@ class ReloadModelsTest < ActiveRecord::TestCase # development environment. Note that meanwhile the class Pet is not # reloaded, simulating a class that is present in a plugin. Object.class_eval { remove_const :Owner } - Kernel.load(File.expand_path(File.join(File.dirname(__FILE__), "../models/owner.rb"))) + Kernel.load(File.expand_path("../models/owner.rb", __dir__)) pet = Pet.find_by_name("parrot") pet.owner = Owner.find_by_name("ashley") assert_equal pet.owner, Owner.find_by_name("ashley") end -end +end unless in_memory_db? diff --git a/activerecord/test/cases/reserved_word_test.rb b/activerecord/test/cases/reserved_word_test.rb new file mode 100644 index 0000000000..e32605fd11 --- /dev/null +++ b/activerecord/test/cases/reserved_word_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ReservedWordTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = true + self.use_transactional_tests = false + + class Group < ActiveRecord::Base + Group.table_name = "group" + belongs_to :select + has_one :values + end + + class Select < ActiveRecord::Base + Select.table_name = "select" + has_many :groups + end + + class Values < ActiveRecord::Base + Values.table_name = "values" + end + + class Distinct < ActiveRecord::Base + Distinct.table_name = "distinct" + has_and_belongs_to_many :selects + has_many :values, through: :groups + end + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :select, force: true + @connection.create_table :distinct, force: true + @connection.create_table :distinct_select, id: false, force: true do |t| + t.belongs_to :distinct + t.belongs_to :select + end + @connection.create_table :group, force: true do |t| + t.string :order + t.belongs_to :select + end + @connection.create_table :values, primary_key: :as, force: true do |t| + t.belongs_to :group + end + end + + def teardown + @connection.drop_table :select, if_exists: true + @connection.drop_table :distinct, if_exists: true + @connection.drop_table :distinct_select, if_exists: true + @connection.drop_table :group, if_exists: true + @connection.drop_table :values, if_exists: true + @connection.drop_table :order, if_exists: true + end + + def test_create_tables + assert_not @connection.table_exists?(:order) + + @connection.create_table :order do |t| + t.string :group + end + + assert @connection.table_exists?(:order) + end + + def test_rename_tables + assert_nothing_raised { @connection.rename_table(:group, :order) } + end + + def test_change_columns + assert_nothing_raised { @connection.change_column_default(:group, :order, "whatever") } + assert_nothing_raised { @connection.change_column("group", "order", :text, default: nil) } + assert_nothing_raised { @connection.rename_column(:group, :order, :values) } + end + + def test_introspect + assert_equal ["id", "order", "select_id"], @connection.columns(:group).map(&:name).sort + assert_equal ["index_group_on_select_id"], @connection.indexes(:group).map(&:name).sort + end + + def test_activerecord_model + x = Group.new + x.order = "x" + x.save! + x.order = "y" + x.save! + assert_equal x, Group.find_by_order("y") + assert_equal x, Group.find(x.id) + end + + def test_delete_all_with_subselect + create_test_fixtures :values + assert_equal 1, Values.order(:as).limit(1).offset(1).delete_all + assert_raise(ActiveRecord::RecordNotFound) { Values.find(2) } + assert Values.find(1) + end + + def test_has_one_associations + create_test_fixtures :group, :values + v = Group.find(1).values + assert_equal 2, v.id + end + + def test_belongs_to_associations + create_test_fixtures :select, :group + gs = Select.find(2).groups + assert_equal 2, gs.length + assert_equal [2, 3], gs.collect(&:id).sort + end + + def test_has_and_belongs_to_many + create_test_fixtures :select, :distinct, :distinct_select + s = Distinct.find(1).selects + assert_equal 2, s.length + assert_equal [1, 2], s.collect(&:id).sort + end + + def test_activerecord_introspection + assert_predicate Group, :table_exists? + assert_equal ["id", "order", "select_id"], Group.columns.map(&:name).sort + end + + def test_calculations_work_with_reserved_words + create_test_fixtures :group + assert_equal 3, Group.count + end + + def test_associations_work_with_reserved_words + create_test_fixtures :select, :group + selects = Select.all.merge!(includes: [:groups]).to_a + assert_no_queries do + selects.each { |select| select.groups } + end + end + + private + # custom fixture loader, uses FixtureSet#create_fixtures and appends base_path to the current file's path + def create_test_fixtures(*fixture_names) + ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/reserved_words", fixture_names) + end +end diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb index 949086fda0..68fcafb682 100644 --- a/activerecord/test/cases/result_test.rb +++ b/activerecord/test/cases/result_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -10,6 +12,11 @@ module ActiveRecord ]) end + test "includes_column?" do + assert result.includes_column?("col_1") + assert_not result.includes_column?("foo") + end + test "length" do assert_equal 3, result.length end @@ -45,10 +52,8 @@ module ActiveRecord end end - if Enumerator.method_defined? :size - test "each without block returns a sized enumerator" do - assert_equal 3, result.each.size - end + test "each without block returns a sized enumerator" do + assert_equal 3, result.each.size end test "cast_values returns rows after type casting" do diff --git a/activerecord/test/cases/sanitize_test.rb b/activerecord/test/cases/sanitize_test.rb index 72f09186e2..778cf86ac3 100644 --- a/activerecord/test/cases/sanitize_test.rb +++ b/activerecord/test/cases/sanitize_test.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + require "cases/helper" require "models/binary" require "models/author" require "models/post" +require "models/customer" class SanitizeTest < ActiveRecord::TestCase def setup @@ -9,30 +12,30 @@ class SanitizeTest < ActiveRecord::TestCase def test_sanitize_sql_array_handles_string_interpolation quoted_bambi = ActiveRecord::Base.connection.quote_string("Bambi") - assert_equal "name='#{quoted_bambi}'", Binary.send(:sanitize_sql_array, ["name='%s'", "Bambi"]) - assert_equal "name='#{quoted_bambi}'", Binary.send(:sanitize_sql_array, ["name='%s'", "Bambi".mb_chars]) + assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi"]) + assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi".mb_chars]) quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote_string("Bambi\nand\nThumper") - assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.send(:sanitize_sql_array, ["name='%s'", "Bambi\nand\nThumper"]) - assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.send(:sanitize_sql_array, ["name='%s'", "Bambi\nand\nThumper".mb_chars]) + assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper"]) + assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper".mb_chars]) end def test_sanitize_sql_array_handles_bind_variables quoted_bambi = ActiveRecord::Base.connection.quote("Bambi") - assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi"]) - assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi".mb_chars]) + assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=?", "Bambi"]) + assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=?", "Bambi".mb_chars]) quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper") - assert_equal "name=#{quoted_bambi_and_thumper}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi\nand\nThumper"]) - assert_equal "name=#{quoted_bambi_and_thumper}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi\nand\nThumper".mb_chars]) + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper".mb_chars]) end def test_sanitize_sql_array_handles_named_bind_variables quoted_bambi = ActiveRecord::Base.connection.quote("Bambi") - assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=:name", name: "Bambi"]) - assert_equal "name=#{quoted_bambi} AND id=1", Binary.send(:sanitize_sql_array, ["name=:name AND id=:id", name: "Bambi", id: 1]) + assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=:name", name: "Bambi"]) + assert_equal "name=#{quoted_bambi} AND id=1", Binary.sanitize_sql_array(["name=:name AND id=:id", name: "Bambi", id: 1]) quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper") - assert_equal "name=#{quoted_bambi_and_thumper}", Binary.send(:sanitize_sql_array, ["name=:name", name: "Bambi\nand\nThumper"]) - assert_equal "name=#{quoted_bambi_and_thumper} AND name2=#{quoted_bambi_and_thumper}", Binary.send(:sanitize_sql_array, ["name=:name AND name2=:name", name: "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name", name: "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper} AND name2=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name AND name2=:name", name: "Bambi\nand\nThumper"]) end def test_sanitize_sql_array_handles_relations @@ -41,42 +44,50 @@ class SanitizeTest < ActiveRecord::TestCase sub_query_pattern = /\(\bselect\b.*?\bwhere\b.*?\)/i - select_author_sql = Post.send(:sanitize_sql_array, ["id in (?)", david_posts]) + select_author_sql = Post.sanitize_sql_array(["id in (?)", david_posts]) assert_match(sub_query_pattern, select_author_sql, "should sanitize `Relation` as subquery for bind variables") - select_author_sql = Post.send(:sanitize_sql_array, ["id in (:post_ids)", post_ids: david_posts]) + select_author_sql = Post.sanitize_sql_array(["id in (:post_ids)", post_ids: david_posts]) assert_match(sub_query_pattern, select_author_sql, "should sanitize `Relation` as subquery for named bind variables") end def test_sanitize_sql_array_handles_empty_statement - select_author_sql = Post.send(:sanitize_sql_array, [""]) + select_author_sql = Post.sanitize_sql_array([""]) assert_equal("", select_author_sql) end def test_sanitize_sql_like - assert_equal '100\%', Binary.send(:sanitize_sql_like, "100%") - assert_equal 'snake\_cased\_string', Binary.send(:sanitize_sql_like, "snake_cased_string") - assert_equal 'C:\\\\Programs\\\\MsPaint', Binary.send(:sanitize_sql_like, 'C:\\Programs\\MsPaint') - assert_equal "normal string 42", Binary.send(:sanitize_sql_like, "normal string 42") + assert_equal '100\%', Binary.sanitize_sql_like("100%") + assert_equal 'snake\_cased\_string', Binary.sanitize_sql_like("snake_cased_string") + assert_equal 'C:\\\\Programs\\\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint') + assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42") end def test_sanitize_sql_like_with_custom_escape_character - assert_equal "100!%", Binary.send(:sanitize_sql_like, "100%", "!") - assert_equal "snake!_cased!_string", Binary.send(:sanitize_sql_like, "snake_cased_string", "!") - assert_equal "great!!", Binary.send(:sanitize_sql_like, "great!", "!") - assert_equal 'C:\\Programs\\MsPaint', Binary.send(:sanitize_sql_like, 'C:\\Programs\\MsPaint', "!") - assert_equal "normal string 42", Binary.send(:sanitize_sql_like, "normal string 42", "!") + assert_equal "100!%", Binary.sanitize_sql_like("100%", "!") + assert_equal "snake!_cased!_string", Binary.sanitize_sql_like("snake_cased_string", "!") + assert_equal "great!!", Binary.sanitize_sql_like("great!", "!") + assert_equal 'C:\\Programs\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint', "!") + assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42", "!") end def test_sanitize_sql_like_example_use_case searchable_post = Class.new(Post) do - def self.search(term) + def self.search_as_method(term) where("title LIKE ?", sanitize_sql_like(term, "!")) end + + scope :search_as_scope, -> (term) { + where("title LIKE ?", sanitize_sql_like(term, "!")) + } end assert_sql(/LIKE '20!% !_reduction!_!!'/) do - searchable_post.search("20% _reduction_!").to_a + searchable_post.search_as_method("20% _reduction_!").to_a + end + + assert_sql(/LIKE '20!% !_reduction!_!!'/) do + searchable_post.search_as_scope("20% _reduction_!").to_a end end @@ -151,24 +162,18 @@ class SanitizeTest < ActiveRecord::TestCase assert_equal "name=#{quoted_bambi_and_thumper}", bind("name=?", "Bambi\nand\nThumper".mb_chars) end - def test_bind_record - o = Class.new { - def quoted_id - 1 - end - }.new - assert_deprecated { assert_equal "1", bind("?", o) } - - os = [o] * 3 - assert_deprecated { assert_equal "1,1,1", bind("?", os) } - end - def test_named_bind_with_postgresql_type_casts l = Proc.new { bind(":a::integer '2009-01-01'::date", a: "10") } assert_nothing_raised(&l) assert_equal "#{ActiveRecord::Base.connection.quote('10')}::integer '2009-01-01'::date", l.call end + def test_deprecated_expand_hash_conditions_for_aggregates + assert_deprecated do + assert_equal({ "balance" => 50 }, Customer.send(:expand_hash_conditions_for_aggregates, balance: Money.new(50))) + end + end + private def bind(statement, *vars) if vars.first.is_a?(Hash) diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb index 9584318e86..75b68b521c 100644 --- a/activerecord/test/cases/schema_dumper_test.rb +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -17,6 +19,12 @@ class SchemaDumperTest < ActiveRecord::TestCase dump_all_table_schema [] end + def test_dump_schema_information_with_empty_versions + ActiveRecord::SchemaMigration.delete_all + schema_info = ActiveRecord::Base.connection.dump_schema_information + assert_no_match(/INSERT INTO/, schema_info) + end + def test_dump_schema_information_outputs_lexically_ordered_versions versions = %w{ 20100101010101 20100201010101 20100301010101 } versions.reverse_each do |v| @@ -29,24 +37,6 @@ class SchemaDumperTest < ActiveRecord::TestCase ActiveRecord::SchemaMigration.delete_all end - if current_adapter?(:SQLite3Adapter) - %w{3.7.8 3.7.11 3.7.12}.each do |version_string| - test "dumps schema version for sqlite version #{version_string}" do - version = ActiveRecord::ConnectionAdapters::SQLite3Adapter::Version.new(version_string) - ActiveRecord::Base.connection.stubs(:sqlite_version).returns(version) - - versions = %w{ 20100101010101 20100201010101 20100301010101 } - versions.reverse_each do |v| - ActiveRecord::SchemaMigration.create!(version: v) - end - - schema_info = ActiveRecord::Base.connection.dump_schema_information - assert_match(/20100201010101.*20100301010101/m, schema_info) - ActiveRecord::SchemaMigration.delete_all - end - end - end - def test_schema_dump output = standard_dump assert_match %r{create_table "accounts"}, output @@ -58,7 +48,7 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dump_uses_force_cascade_on_create_table output = dump_table_schema "authors" - assert_match %r{create_table "authors", force: :cascade}, output + assert_match %r{create_table "authors",.* force: :cascade}, output end def test_schema_dump_excludes_sqlite_sequence @@ -116,32 +106,22 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dump_includes_limit_constraint_for_integer_columns output = dump_all_table_schema([/^(?!integer_limits)/]) - assert_match %r{c_int_without_limit}, output + assert_match %r{"c_int_without_limit"(?!.*limit)}, output if current_adapter?(:PostgreSQLAdapter) - assert_no_match %r{c_int_without_limit.*limit:}, output - assert_match %r{c_int_1.*limit: 2}, output assert_match %r{c_int_2.*limit: 2}, output # int 3 is 4 bytes in postgresql - assert_match %r{c_int_3.*}, output - assert_no_match %r{c_int_3.*limit:}, output - - assert_match %r{c_int_4.*}, output - assert_no_match %r{c_int_4.*limit:}, output + assert_match %r{"c_int_3"(?!.*limit)}, output + assert_match %r{"c_int_4"(?!.*limit)}, output elsif current_adapter?(:Mysql2Adapter) - assert_match %r{c_int_without_limit"$}, output - assert_match %r{c_int_1.*limit: 1}, output assert_match %r{c_int_2.*limit: 2}, output assert_match %r{c_int_3.*limit: 3}, output - assert_match %r{c_int_4.*}, output - assert_no_match %r{c_int_4.*:limit}, output + assert_match %r{"c_int_4"(?!.*limit)}, output elsif current_adapter?(:SQLite3Adapter) - assert_no_match %r{c_int_without_limit.*limit:}, output - assert_match %r{c_int_1.*limit: 1}, output assert_match %r{c_int_2.*limit: 2}, output assert_match %r{c_int_3.*limit: 3}, output @@ -179,10 +159,14 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dumps_index_columns_in_right_order index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_index/).first.strip - if current_adapter?(:PostgreSQLAdapter) + if current_adapter?(:Mysql2Adapter) + if ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }, order: { rating: :desc }', index_definition + else + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }', index_definition + end + elsif ActiveRecord::Base.connection.supports_index_sort_order? assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", order: { rating: :desc }', index_definition - elsif current_adapter?(:Mysql2Adapter) - assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }', index_definition else assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index"', index_definition end @@ -190,13 +174,31 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dumps_partial_indices index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_partial_index/).first.strip - if current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter) && ActiveRecord::Base.connection.supports_partial_index? + if ActiveRecord::Base.connection.supports_partial_index? assert_equal 't.index ["firm_id", "type"], name: "company_partial_index", where: "(rating > 10)"', index_definition else assert_equal 't.index ["firm_id", "type"], name: "company_partial_index"', index_definition end end + def test_schema_dumps_index_sort_order + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_rating/).first.strip + if ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating", order: :desc', index_definition + else + assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating"', index_definition + end + end + + def test_schema_dumps_index_length + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_description/).first.strip + if current_adapter?(:Mysql2Adapter) + assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description", length: 10', index_definition + else + assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description"', index_definition + end + end + def test_schema_dump_should_honor_nonstandard_primary_keys output = standard_dump match = output.match(%r{create_table "movies"(.*)do}) @@ -205,20 +207,25 @@ class SchemaDumperTest < ActiveRecord::TestCase end def test_schema_dump_should_use_false_as_default - output = standard_dump + output = dump_table_schema "booleans" assert_match %r{t\.boolean\s+"has_fun",.+default: false}, output end def test_schema_dump_does_not_include_limit_for_text_field - output = standard_dump + output = dump_table_schema "admin_users" assert_match %r{t\.text\s+"params"$}, output end def test_schema_dump_does_not_include_limit_for_binary_field - output = standard_dump + output = dump_table_schema "binaries" assert_match %r{t\.binary\s+"data"$}, output end + def test_schema_dump_does_not_include_limit_for_float_field + output = dump_table_schema "numeric_data" + assert_match %r{t\.float\s+"temperature"$}, output + end + if current_adapter?(:Mysql2Adapter) def test_schema_dump_includes_length_for_mysql_binary_fields output = standard_dump @@ -273,7 +280,7 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dump_expression_indices index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_expression_index/).first.strip - assert_equal 't.index "lower((name)::text)", name: "company_expression_index"', index_definition + assert_match %r{CASE.+lower\(\(name\)::text\)}i, index_definition end def test_schema_dump_interval_type @@ -287,21 +294,37 @@ class SchemaDumperTest < ActiveRecord::TestCase assert_match %r{t\.oid\s+"obj_id"$}, output end - if ActiveRecord::Base.connection.supports_extensions? - def test_schema_dump_includes_extensions - connection = ActiveRecord::Base.connection + def test_schema_dump_includes_extensions + connection = ActiveRecord::Base.connection - connection.stubs(:extensions).returns(["hstore"]) + connection.stub(:extensions, ["hstore"]) do output = perform_schema_dump assert_match "# These are extensions that must be enabled", output assert_match %r{enable_extension "hstore"}, output + end - connection.stubs(:extensions).returns([]) + connection.stub(:extensions, []) do output = perform_schema_dump assert_no_match "# These are extensions that must be enabled", output assert_no_match %r{enable_extension}, output end end + + def test_schema_dump_includes_extensions_in_alphabetic_order + connection = ActiveRecord::Base.connection + + connection.stub(:extensions, ["hstore", "uuid-ossp", "xml2"]) do + output = perform_schema_dump + enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten + assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions + end + + connection.stub(:extensions, ["uuid-ossp", "xml2", "hstore"]) do + output = perform_schema_dump + enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten + assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions + end + end end def test_schema_dump_keeps_large_precision_integer_columns_as_decimal @@ -326,7 +349,7 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dump_keeps_id_false_when_id_is_false_and_unique_not_null_column_added output = standard_dump - assert_match %r{create_table "subscribers", id: false}, output + assert_match %r{create_table "string_key_objects", id: false}, output end if ActiveRecord::Base.connection.supports_foreign_keys? @@ -384,6 +407,31 @@ class SchemaDumperTest < ActiveRecord::TestCase $stdout = original end + def test_schema_dump_with_table_name_prefix_and_suffix_regexp_escape + original, $stdout = $stdout, StringIO.new + ActiveRecord::Base.table_name_prefix = "foo$" + ActiveRecord::Base.table_name_suffix = "$bar" + + migration = CreateDogMigration.new + migration.migrate(:up) + + output = perform_schema_dump + assert_no_match %r{create_table "foo\$.+\$bar"}, output + assert_no_match %r{add_index "foo\$.+\$bar"}, output + assert_no_match %r{create_table "schema_migrations"}, output + assert_no_match %r{create_table "ar_internal_metadata"}, output + + if ActiveRecord::Base.connection.supports_foreign_keys? + assert_no_match %r{add_foreign_key "foo\$.+\$bar"}, output + assert_no_match %r{add_foreign_key "[^"]+", "foo\$.+\$bar"}, output + end + ensure + migration.migrate(:down) + + ActiveRecord::Base.table_name_suffix = ActiveRecord::Base.table_name_prefix = "" + $stdout = original + end + def test_schema_dump_with_table_name_prefix_and_ignoring_tables original, $stdout = $stdout, StringIO.new @@ -407,7 +455,7 @@ class SchemaDumperTest < ActiveRecord::TestCase output = ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream).string assert_match %r{create_table "omg_cats"}, output - refute_match %r{create_table "cats"}, output + assert_no_match %r{create_table "cats"}, output ensure migration.migrate(:down) ActiveRecord::Base.table_name_prefix = original_table_name_prefix @@ -422,11 +470,12 @@ class SchemaDumperDefaultsTest < ActiveRecord::TestCase setup do @connection = ActiveRecord::Base.connection - @connection.create_table :defaults, force: true do |t| + @connection.create_table :dump_defaults, force: true do |t| t.string :string_with_default, default: "Hello!" t.date :date_with_default, default: "2014-06-05" t.datetime :datetime_with_default, default: "2014-06-05 07:17:04" t.time :time_with_default, default: "07:17:04" + t.decimal :decimal_with_default, default: "1234567890.0123456789", precision: 20, scale: 10 end if current_adapter?(:PostgreSQLAdapter) @@ -438,17 +487,17 @@ class SchemaDumperDefaultsTest < ActiveRecord::TestCase end teardown do - return unless @connection - @connection.drop_table "defaults", if_exists: true + @connection.drop_table "dump_defaults", if_exists: true end def test_schema_dump_defaults_with_universally_supported_types - output = dump_table_schema("defaults") + output = dump_table_schema("dump_defaults") assert_match %r{t\.string\s+"string_with_default",.*?default: "Hello!"}, output - assert_match %r{t\.date\s+"date_with_default",\s+default: '2014-06-05'}, output - assert_match %r{t\.datetime\s+"datetime_with_default",\s+default: '2014-06-05 07:17:04'}, output - assert_match %r{t\.time\s+"time_with_default",\s+default: '2000-01-01 07:17:04'}, output + assert_match %r{t\.date\s+"date_with_default",\s+default: "2014-06-05"}, output + assert_match %r{t\.datetime\s+"datetime_with_default",\s+default: "2014-06-05 07:17:04"}, output + assert_match %r{t\.time\s+"time_with_default",\s+default: "2000-01-01 07:17:04"}, output + assert_match %r{t\.decimal\s+"decimal_with_default",\s+precision: 20,\s+scale: 10,\s+default: "1234567890.0123456789"}, output end def test_schema_dump_with_float_column_infinity_default diff --git a/activerecord/test/cases/schema_loading_test.rb b/activerecord/test/cases/schema_loading_test.rb index 362370ac61..f539156466 100644 --- a/activerecord/test/cases/schema_loading_test.rb +++ b/activerecord/test/cases/schema_loading_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module SchemaLoadCounter diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb index 14fb2fbbfa..6281712df6 100644 --- a/activerecord/test/cases/scoping/default_scoping_test.rb +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/comment" require "models/developer" +require "models/project" require "models/computer" require "models/vehicle" require "models/cat" @@ -10,8 +13,6 @@ require "concurrent/atomic/cyclic_barrier" class DefaultScopingTest < ActiveRecord::TestCase fixtures :developers, :posts, :comments - self.use_transactional_tests = false - def test_default_scope expected = Developer.all.merge!(order: "salary DESC").to_a.collect(&:salary) received = DeveloperOrderedBySalary.all.collect(&:salary) @@ -61,17 +62,6 @@ class DefaultScopingTest < ActiveRecord::TestCase assert_equal "Jamis", DeveloperCalledJamis.create!.name end - unless in_memory_db? - def test_default_scoping_with_threads - 2.times do - Thread.new { - assert_includes DeveloperOrderedBySalary.all.to_sql, "salary DESC" - DeveloperOrderedBySalary.connection.close - }.join - end - end - end - def test_default_scope_with_inheritance wheres = InheritedPoorDeveloperCalledJamis.all.where_values_hash assert_equal "Jamis", wheres["name"] @@ -131,49 +121,49 @@ class DefaultScopingTest < ActiveRecord::TestCase def test_unscope_with_where_attributes expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(name: "David").unscope(where: :name).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort expected_2 = Developer.order("salary DESC").collect(&:name) received_2 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope({ where: :name }, :select).collect(&:name) - assert_equal expected_2, received_2 + assert_equal expected_2.sort, received_2.sort expected_3 = Developer.order("salary DESC").collect(&:name) received_3 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope(:select, :where).collect(&:name) - assert_equal expected_3, received_3 + assert_equal expected_3.sort, received_3.sort expected_4 = Developer.order("salary DESC").collect(&:name) received_4 = DeveloperOrderedBySalary.where.not("name" => "Jamis").unscope(where: :name).collect(&:name) - assert_equal expected_4, received_4 + assert_equal expected_4.sort, received_4.sort expected_5 = Developer.order("salary DESC").collect(&:name) received_5 = DeveloperOrderedBySalary.where.not("name" => ["Jamis", "David"]).unscope(where: :name).collect(&:name) - assert_equal expected_5, received_5 + assert_equal expected_5.sort, received_5.sort expected_6 = Developer.order("salary DESC").collect(&:name) received_6 = DeveloperOrderedBySalary.where(Developer.arel_table["name"].eq("David")).unscope(where: :name).collect(&:name) - assert_equal expected_6, received_6 + assert_equal expected_6.sort, received_6.sort expected_7 = Developer.order("salary DESC").collect(&:name) received_7 = DeveloperOrderedBySalary.where(Developer.arel_table[:name].eq("David")).unscope(where: :name).collect(&:name) - assert_equal expected_7, received_7 + assert_equal expected_7.sort, received_7.sort end def test_unscope_comparison_where_clauses # unscoped for WHERE (`developers`.`id` <= 2) expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(id: -Float::INFINITY..2).unscope(where: :id).collect { |dev| dev.name } - assert_equal expected, received + assert_equal expected.sort, received.sort # unscoped for WHERE (`developers`.`id` < 2) expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(id: -Float::INFINITY...2).unscope(where: :id).collect { |dev| dev.name } - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_unscope_multiple_where_clauses expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(name: "Jamis").where(id: 1).unscope(where: [:name, :id]).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_unscope_string_where_clauses_involved @@ -183,28 +173,28 @@ class DefaultScopingTest < ActiveRecord::TestCase dev_ordered_relation = DeveloperOrderedBySalary.where(name: "Jamis").where("created_at > ?", 1.year.ago) received = dev_ordered_relation.unscope(where: [:name]).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_unscope_with_grouping_attributes expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.group(:name).unscope(:group).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort expected_2 = Developer.order("salary DESC").collect(&:name) received_2 = DeveloperOrderedBySalary.group("name").unscope(:group).collect(&:name) - assert_equal expected_2, received_2 + assert_equal expected_2.sort, received_2.sort end def test_unscope_with_limit_in_query expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.limit(1).unscope(:limit).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_order_to_unscope_reordering scope = DeveloperOrderedBySalary.order("salary DESC, name ASC").reverse_order.unscope(:order) - assert !/order/i.match?(scope.to_sql) + assert_no_match(/order/i, scope.to_sql) end def test_unscope_reverse_order @@ -235,6 +225,18 @@ class DefaultScopingTest < ActiveRecord::TestCase assert_equal expected, received end + def test_unscope_left_outer_joins + expected = Developer.all.collect(&:name) + received = Developer.left_outer_joins(:projects).select(:id).unscope(:left_outer_joins, :select).collect(&:name) + assert_equal expected, received + end + + def test_unscope_left_joins + expected = Developer.all.collect(&:name) + received = Developer.left_joins(:projects).select(:id).unscope(:left_joins, :select).collect(&:name) + assert_equal expected, received + end + def test_unscope_includes expected = Developer.all.collect(&:name) received = Developer.includes(:projects).select(:id).unscope(:includes, :select).collect(&:name) @@ -301,8 +303,8 @@ class DefaultScopingTest < ActiveRecord::TestCase def test_unscope_merging merged = Developer.where(name: "Jamis").merge(Developer.unscope(:where)) - assert merged.where_clause.empty? - assert !merged.where(name: "Jon").where_clause.empty? + assert_empty merged.where_clause + assert_not_empty merged.where(name: "Jon").where_clause end def test_order_in_default_scope_should_not_prevail @@ -345,7 +347,7 @@ class DefaultScopingTest < ActiveRecord::TestCase def test_create_with_merge aaron = PoorDeveloperCalledJamis.create_with(name: "foo", salary: 20).merge( - PoorDeveloperCalledJamis.create_with(name: "Aaron")).new + PoorDeveloperCalledJamis.create_with(name: "Aaron")).new assert_equal 20, aaron.salary assert_equal "Aaron", aaron.name @@ -355,11 +357,31 @@ class DefaultScopingTest < ActiveRecord::TestCase assert_equal "Aaron", aaron.name end + def test_create_with_using_both_string_and_symbol + jamis = PoorDeveloperCalledJamis.create_with(name: "foo").create_with("name" => "Aaron").new + assert_equal "Aaron", jamis.name + end + def test_create_with_reset jamis = PoorDeveloperCalledJamis.create_with(name: "Aaron").create_with(nil).new assert_equal "Jamis", jamis.name end + def test_create_with_takes_precedence_over_where + developer = Developer.where(name: nil).create_with(name: "Aaron").new + assert_equal "Aaron", developer.name + end + + def test_create_with_nested_attributes + assert_difference("Project.count", 1) do + Developer.create_with( + projects_attributes: [{ name: "p1" }] + ).scoping do + Developer.create!(name: "Aaron") + end + end + end + # FIXME: I don't know if this is *desired* behavior, but it is *today's* # behavior. def test_create_with_empty_hash_will_not_reset @@ -385,11 +407,39 @@ class DefaultScopingTest < ActiveRecord::TestCase Comment.joins(:post).count end + def test_joins_not_affected_by_scope_other_than_default_or_unscoped + without_scope_on_post = Comment.joins(:post).to_a + with_scope_on_post = nil + Post.where(id: [1, 5, 6]).scoping do + with_scope_on_post = Comment.joins(:post).to_a + end + + assert_equal with_scope_on_post, without_scope_on_post + end + def test_unscoped_with_joins_should_not_have_default_scope assert_equal SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).to_a }, Comment.joins(:post).to_a end + def test_sti_association_with_unscoped_not_affected_by_default_scope + post = posts(:thinking) + comments = [comments(:does_it_hurt)] + + post.special_comments.update_all(deleted_at: Time.now) + + assert_raises(ActiveRecord::RecordNotFound) { Post.joins(:special_comments).find(post.id) } + assert_equal [], post.special_comments + + SpecialComment.unscoped do + assert_equal post, Post.joins(:special_comments).find(post.id) + assert_equal comments, Post.joins(:special_comments).find(post.id).special_comments + assert_equal comments, Post.eager_load(:special_comments).find(post.id).special_comments + assert_equal comments, Post.includes(:special_comments).find(post.id).special_comments + assert_equal comments, Post.preload(:special_comments).find(post.id).special_comments + end + end + def test_default_scope_select_ignored_by_aggregations assert_equal DeveloperWithSelect.all.to_a.count, DeveloperWithSelect.count end @@ -435,29 +485,6 @@ class DefaultScopingTest < ActiveRecord::TestCase assert_equal comment, CommentWithDefaultScopeReferencesAssociation.find_by(id: comment.id) end - unless in_memory_db? - def test_default_scope_is_threadsafe - threads = [] - assert_not_equal 1, ThreadsafeDeveloper.unscoped.count - - barrier_1 = Concurrent::CyclicBarrier.new(2) - barrier_2 = Concurrent::CyclicBarrier.new(2) - - threads << Thread.new do - Thread.current[:default_scope_delay] = -> { barrier_1.wait; barrier_2.wait } - assert_equal 1, ThreadsafeDeveloper.all.to_a.count - ThreadsafeDeveloper.connection.close - end - threads << Thread.new do - Thread.current[:default_scope_delay] = -> { barrier_2.wait } - barrier_1.wait - assert_equal 1, ThreadsafeDeveloper.all.to_a.count - ThreadsafeDeveloper.connection.close - end - threads.each(&:join) - end - end - test "additional conditions are ANDed with the default scope" do scope = DeveloperCalledJamis.where(name: "David") assert_equal 2, scope.where_clause.ast.children.length @@ -473,7 +500,7 @@ class DefaultScopingTest < ActiveRecord::TestCase test "a scope can remove the condition from the default scope" do scope = DeveloperCalledJamis.david2 assert_equal 1, scope.where_clause.ast.children.length - assert_equal Developer.where(name: "David"), scope + assert_equal Developer.where(name: "David").map(&:id), scope.map(&:id) end def test_with_abstract_class_where_clause_should_not_be_duplicated @@ -498,6 +525,8 @@ class DefaultScopingTest < ActiveRecord::TestCase def test_with_abstract_class_scope_should_be_executed_in_correct_context vegetarian_pattern, gender_pattern = if current_adapter?(:Mysql2Adapter) [/`lions`.`is_vegetarian`/, /`lions`.`gender`/] + elsif current_adapter?(:OracleAdapter) + [/"LIONS"."IS_VEGETARIAN"/, /"LIONS"."GENDER"/] else [/"lions"."is_vegetarian"/, /"lions"."gender"/] end @@ -506,3 +535,41 @@ class DefaultScopingTest < ActiveRecord::TestCase assert_match gender_pattern, Lion.female.to_sql end end + +class DefaultScopingWithThreadTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + def test_default_scoping_with_threads + 2.times do + Thread.new { + assert_includes DeveloperOrderedBySalary.all.to_sql, "salary DESC" + DeveloperOrderedBySalary.connection.close + }.join + end + end + + def test_default_scope_is_threadsafe + 2.times { ThreadsafeDeveloper.unscoped.create! } + + threads = [] + assert_not_equal 1, ThreadsafeDeveloper.unscoped.count + + barrier_1 = Concurrent::CyclicBarrier.new(2) + barrier_2 = Concurrent::CyclicBarrier.new(2) + + threads << Thread.new do + Thread.current[:default_scope_delay] = -> { barrier_1.wait; barrier_2.wait } + assert_equal 1, ThreadsafeDeveloper.all.to_a.count + ThreadsafeDeveloper.connection.close + end + threads << Thread.new do + Thread.current[:default_scope_delay] = -> { barrier_2.wait } + barrier_1.wait + assert_equal 1, ThreadsafeDeveloper.all.to_a.count + ThreadsafeDeveloper.connection.close + end + threads.each(&:join) + ensure + ThreadsafeDeveloper.unscoped.destroy_all + end +end unless in_memory_db? diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb index d261fd5321..4214f347fb 100644 --- a/activerecord/test/cases/scoping/named_scoping_test.rb +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/topic" @@ -11,7 +13,7 @@ class NamedScopingTest < ActiveRecord::TestCase fixtures :posts, :authors, :topics, :comments, :author_addresses def test_implements_enumerable - assert !Topic.all.empty? + assert_not_empty Topic.all assert_equal Topic.all.to_a, Topic.base assert_equal Topic.all.to_a, Topic.base.to_a @@ -23,8 +25,8 @@ class NamedScopingTest < ActiveRecord::TestCase all_posts = Topic.base assert_queries(1) do - all_posts.collect - all_posts.collect + all_posts.collect { true } + all_posts.collect { true } end end @@ -38,7 +40,7 @@ class NamedScopingTest < ActiveRecord::TestCase end def test_delegates_finds_and_calculations_to_the_base_class - assert !Topic.all.empty? + assert_not_empty Topic.all assert_equal Topic.all.to_a, Topic.base.to_a assert_equal Topic.first, Topic.base.first @@ -63,13 +65,13 @@ class NamedScopingTest < ActiveRecord::TestCase end def test_scope_should_respond_to_own_methods_and_methods_of_the_proxy - assert Topic.approved.respond_to?(:limit) - assert Topic.approved.respond_to?(:count) - assert Topic.approved.respond_to?(:length) + assert_respond_to Topic.approved, :limit + assert_respond_to Topic.approved, :count + assert_respond_to Topic.approved, :length end def test_scopes_with_options_limit_finds_to_those_matching_the_criteria_specified - assert !Topic.all.merge!(where: { approved: true }).to_a.empty? + assert_not_empty Topic.all.merge!(where: { approved: true }).to_a assert_equal Topic.all.merge!(where: { approved: true }).to_a, Topic.approved assert_equal Topic.where(approved: true).count, Topic.approved.count @@ -84,8 +86,8 @@ class NamedScopingTest < ActiveRecord::TestCase def test_scopes_are_composable assert_equal((approved = Topic.all.merge!(where: { approved: true }).to_a), Topic.approved) assert_equal((replied = Topic.all.merge!(where: "replies_count > 0").to_a), Topic.replied) - assert !(approved == replied) - assert !(approved & replied).empty? + assert_not (approved == replied) + assert_not_empty (approved & replied) assert_equal approved & replied, Topic.approved.replied end @@ -113,9 +115,10 @@ class NamedScopingTest < ActiveRecord::TestCase def test_has_many_associations_have_access_to_scopes assert_not_equal Post.containing_the_letter_a, authors(:david).posts - assert !Post.containing_the_letter_a.empty? + assert_not_empty Post.containing_the_letter_a - assert_equal authors(:david).posts & Post.containing_the_letter_a, authors(:david).posts.containing_the_letter_a + expected = authors(:david).posts & Post.containing_the_letter_a + assert_equal expected.sort_by(&:id), authors(:david).posts.containing_the_letter_a.sort_by(&:id) end def test_scope_with_STI @@ -125,14 +128,15 @@ class NamedScopingTest < ActiveRecord::TestCase def test_has_many_through_associations_have_access_to_scopes assert_not_equal Comment.containing_the_letter_e, authors(:david).comments - assert !Comment.containing_the_letter_e.empty? + assert_not_empty Comment.containing_the_letter_e - assert_equal authors(:david).comments & Comment.containing_the_letter_e, authors(:david).comments.containing_the_letter_e + expected = authors(:david).comments & Comment.containing_the_letter_e + assert_equal expected.sort_by(&:id), authors(:david).comments.containing_the_letter_e.sort_by(&:id) end def test_scopes_honor_current_scopes_from_when_defined - assert !Post.ranked_by_comments.limit_by(5).empty? - assert !authors(:david).posts.ranked_by_comments.limit_by(5).empty? + assert_not_empty Post.ranked_by_comments.limit_by(5) + assert_not_empty authors(:david).posts.ranked_by_comments.limit_by(5) assert_not_equal Post.ranked_by_comments.limit_by(5), authors(:david).posts.ranked_by_comments.limit_by(5) assert_not_equal Post.top(5), authors(:david).posts.top(5) # Oracle sometimes sorts differently if WHERE condition is changed @@ -147,15 +151,31 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal "The scope body needs to be callable.", e.message end + def test_scopes_name_is_relation_method + conflicts = [ + :records, + :to_ary, + :to_sql, + :explain + ] + + conflicts.each do |name| + e = assert_raises ArgumentError do + Class.new(Post).class_eval { scope name, -> { where(approved: true) } } + end + assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message) + end + end + def test_active_records_have_scope_named__all__ - assert !Topic.all.empty? + assert_not_empty Topic.all assert_equal Topic.all.to_a, Topic.base end def test_active_records_have_scope_named__scoped__ scope = Topic.where("content LIKE '%Have%'") - assert !scope.empty? + assert_not_empty scope assert_equal scope, Topic.all.merge!(where: "content LIKE '%Have%'") end @@ -167,7 +187,7 @@ class NamedScopingTest < ActiveRecord::TestCase def test_first_and_last_should_not_use_query_when_results_are_loaded topics = Topic.base - topics.reload # force load + topics.load # force load assert_no_queries do topics.first topics.last @@ -178,7 +198,7 @@ class NamedScopingTest < ActiveRecord::TestCase topics = Topic.base assert_queries(2) do topics.empty? # use count query - topics.collect # force load + topics.load # force load topics.empty? # use loaded (no query) end end @@ -187,7 +207,7 @@ class NamedScopingTest < ActiveRecord::TestCase topics = Topic.base assert_queries(2) do topics.any? # use count query - topics.collect # force load + topics.load # force load topics.any? # use loaded (no query) end end @@ -203,21 +223,21 @@ class NamedScopingTest < ActiveRecord::TestCase def test_any_should_not_fire_query_if_scope_loaded topics = Topic.base - topics.collect # force load + topics.load # force load assert_no_queries { assert topics.any? } end def test_model_class_should_respond_to_any - assert Topic.any? + assert_predicate Topic, :any? Topic.delete_all - assert !Topic.any? + assert_not_predicate Topic, :any? end def test_many_should_not_load_results topics = Topic.base assert_queries(2) do topics.many? # use count query - topics.collect # force load + topics.load # force load topics.many? # use loaded (no query) end end @@ -233,28 +253,28 @@ class NamedScopingTest < ActiveRecord::TestCase def test_many_should_not_fire_query_if_scope_loaded topics = Topic.base - topics.collect # force load + topics.load # force load assert_no_queries { assert topics.many? } end def test_many_should_return_false_if_none_or_one topics = Topic.base.where(id: 0) - assert !topics.many? + assert_not_predicate topics, :many? topics = Topic.base.where(id: 1) - assert !topics.many? + assert_not_predicate topics, :many? end def test_many_should_return_true_if_more_than_one - assert Topic.base.many? + assert_predicate Topic.base, :many? end def test_model_class_should_respond_to_many Topic.delete_all - assert !Topic.many? + assert_not_predicate Topic, :many? Topic.create! - assert !Topic.many? + assert_not_predicate Topic, :many? Topic.create! - assert Topic.many? + assert_predicate Topic, :many? end def test_should_build_on_top_of_scope @@ -283,6 +303,13 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal "lifo", topic.author_name end + def test_deprecated_delegating_private_method + assert_deprecated do + scope = Topic.all.by_private_lifo + assert_not scope.instance_variable_get(:@delegate_to_klass) + end + end + def test_reserved_scope_names klass = Class.new(ActiveRecord::Base) do self.table_name = "topics" @@ -384,7 +411,7 @@ class NamedScopingTest < ActiveRecord::TestCase def test_size_should_use_length_when_results_are_loaded topics = Topic.base - topics.reload # force load + topics.load # force load assert_no_queries do topics.size # use loaded (no query) end @@ -403,16 +430,16 @@ class NamedScopingTest < ActiveRecord::TestCase def test_chaining_applies_last_conditions_when_creating post = Topic.rejected.new - assert !post.approved? + assert_not_predicate post, :approved? post = Topic.rejected.approved.new - assert post.approved? + assert_predicate post, :approved? post = Topic.approved.rejected.new - assert !post.approved? + assert_not_predicate post, :approved? post = Topic.approved.rejected.approved.new - assert post.approved? + assert_predicate post, :approved? end def test_chaining_combines_conditions_when_searching @@ -461,8 +488,9 @@ class NamedScopingTest < ActiveRecord::TestCase [:public_method, :protected_method, :private_method].each do |reserved_method| assert Topic.respond_to?(reserved_method, true) - ActiveRecord::Base.logger.expects(:warn) - silence_warnings { Topic.scope(reserved_method, -> {}) } + assert_called(ActiveRecord::Base.logger, :warn) do + silence_warnings { Topic.scope(reserved_method, -> {}) } + end end end @@ -478,7 +506,7 @@ class NamedScopingTest < ActiveRecord::TestCase def test_index_on_scope approved = Topic.approved.order("id ASC") assert_equal topics(:second), approved[0] - assert approved.loaded? + assert_predicate approved, :loaded? end def test_nested_scopes_queries_size @@ -551,17 +579,23 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal 1, SpecialComment.where(body: "go crazy").created.count end + def test_model_class_should_respond_to_extending + assert_raises OopsError do + Comment.unscoped.oops_comments.destroy_all + end + end + def test_model_class_should_respond_to_none - assert !Topic.none? + assert_not_predicate Topic, :none? Topic.delete_all - assert Topic.none? + assert_predicate Topic, :none? end def test_model_class_should_respond_to_one - assert !Topic.one? + assert_not_predicate Topic, :one? Topic.delete_all - assert !Topic.one? + assert_not_predicate Topic, :one? Topic.create! - assert Topic.one? + assert_predicate Topic, :one? end end diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb index a1ae57fdbb..b4f4379e5e 100644 --- a/activerecord/test/cases/scoping/relation_scoping_test.rb +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/post" require "models/author" @@ -10,7 +12,7 @@ require "models/person" require "models/reference" class RelationScopingTest < ActiveRecord::TestCase - fixtures :authors, :developers, :projects, :comments, :posts, :developers_projects + fixtures :authors, :author_addresses, :developers, :projects, :comments, :posts, :developers_projects setup do developers(:david) @@ -103,7 +105,7 @@ class RelationScopingTest < ActiveRecord::TestCase Developer.select("id, name").scoping do developer = Developer.where("name = 'David'").first assert_equal "David", developer.name - assert !developer.has_attribute?(:salary) + assert_not developer.has_attribute?(:salary) end end @@ -211,34 +213,69 @@ class RelationScopingTest < ActiveRecord::TestCase def test_current_scope_does_not_pollute_sibling_subclasses Comment.none.scoping do - assert_not SpecialComment.all.any? - assert_not VerySpecialComment.all.any? - assert_not SubSpecialComment.all.any? + assert_not_predicate SpecialComment.all, :any? + assert_not_predicate VerySpecialComment.all, :any? + assert_not_predicate SubSpecialComment.all, :any? end SpecialComment.none.scoping do - assert Comment.all.any? - assert VerySpecialComment.all.any? - assert_not SubSpecialComment.all.any? + assert_predicate Comment.all, :any? + assert_predicate VerySpecialComment.all, :any? + assert_not_predicate SubSpecialComment.all, :any? end SubSpecialComment.none.scoping do - assert Comment.all.any? - assert VerySpecialComment.all.any? - assert SpecialComment.all.any? + assert_predicate Comment.all, :any? + assert_predicate VerySpecialComment.all, :any? + assert_predicate SpecialComment.all, :any? + end + end + + def test_scoping_is_correctly_restored + Comment.unscoped do + SpecialComment.unscoped.created + end + + assert_nil Comment.send(:current_scope) + assert_nil SpecialComment.send(:current_scope) + end + + def test_scoping_respects_current_class + Comment.unscoped do + assert_equal "a comment...", Comment.all.what_are_you + assert_equal "a special comment...", SpecialComment.all.what_are_you end end - def test_circular_joins_with_current_scope_does_not_crash + def test_scoping_respects_sti_constraint + Comment.unscoped do + assert_equal comments(:greetings), Comment.find(1) + assert_raises(ActiveRecord::RecordNotFound) { SpecialComment.find(1) } + end + end + + def test_scoping_works_in_the_scope_block + expected = SpecialPostWithDefaultScope.unscoped.to_a + assert_equal expected, SpecialPostWithDefaultScope.unscoped_all + end + + def test_circular_joins_with_scoping_does_not_crash posts = Post.joins(comments: :post).scoping do - Post.current_scope.first(10) + Post.first(10) end assert_equal posts, Post.joins(comments: :post).first(10) end + + def test_circular_left_joins_with_scoping_does_not_crash + posts = Post.left_joins(comments: :post).scoping do + Post.first(10) + end + assert_equal posts, Post.left_joins(comments: :post).first(10) + end end class NestedRelationScopingTest < ActiveRecord::TestCase - fixtures :authors, :developers, :projects, :comments, :posts + fixtures :authors, :author_addresses, :developers, :projects, :comments, :posts def test_merge_options Developer.where("salary = 80000").scoping do @@ -302,7 +339,7 @@ class NestedRelationScopingTest < ActiveRecord::TestCase def test_nested_exclusive_scope_for_create comment = Comment.create_with(body: "Hey guys, nested scopes are broken. Please fix!").scoping do Comment.unscoped.create_with(post_id: 1).scoping do - assert Comment.new.body.blank? + assert_predicate Comment.new.body, :blank? Comment.create body: "Hey guys" end end diff --git a/activerecord/test/cases/secure_token_test.rb b/activerecord/test/cases/secure_token_test.rb index 7b9cbee40a..f5fa6aa302 100644 --- a/activerecord/test/cases/secure_token_test.rb +++ b/activerecord/test/cases/secure_token_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/user" diff --git a/activerecord/test/cases/serialization_test.rb b/activerecord/test/cases/serialization_test.rb index ec33ad38f2..932780bfef 100644 --- a/activerecord/test/cases/serialization_test.rb +++ b/activerecord/test/cases/serialization_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/contact" require "models/topic" @@ -65,8 +67,8 @@ class SerializationTest < ActiveRecord::TestCase klazz.include_root_in_json = false assert ActiveRecord::Base.include_root_in_json - assert !klazz.include_root_in_json - assert !klazz.new.include_root_in_json + assert_not klazz.include_root_in_json + assert_not klazz.new.include_root_in_json ensure ActiveRecord::Base.include_root_in_json = original_root_in_json end diff --git a/activerecord/test/cases/serialized_attribute_test.rb b/activerecord/test/cases/serialized_attribute_test.rb index 673392b4c4..1192b30b14 100644 --- a/activerecord/test/cases/serialized_attribute_test.rb +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" @@ -157,6 +159,13 @@ class SerializedAttributeTest < ActiveRecord::TestCase assert_equal(settings, Topic.find(topic.id).content) end + def test_where_by_serialized_attribute_with_array + settings = [ "color" => "green" ] + Topic.serialize(:content, Array) + topic = Topic.create!(content: settings) + assert_equal topic, Topic.where(content: settings).take + end + def test_where_by_serialized_attribute_with_hash settings = { "color" => "green" } Topic.serialize(:content, Hash) @@ -164,6 +173,13 @@ class SerializedAttributeTest < ActiveRecord::TestCase assert_equal topic, Topic.where(content: settings).take end + def test_where_by_serialized_attribute_with_hash_in_array + settings = { "color" => "green" } + Topic.serialize(:content, Hash) + topic = Topic.create!(content: settings) + assert_equal topic, Topic.where(content: [settings]).take + end + def test_serialized_default_class Topic.serialize(:content, Hash) topic = Topic.new @@ -277,7 +293,7 @@ class SerializedAttributeTest < ActiveRecord::TestCase topic = Topic.new(content: nil) - assert_not topic.content_changed? + assert_not_predicate topic, :content_changed? end def test_classes_without_no_arg_constructors_are_not_supported @@ -306,7 +322,7 @@ class SerializedAttributeTest < ActiveRecord::TestCase topic = Topic.create!(content: {}) topic2 = Topic.create!(content: nil) - assert_equal [topic, topic2], Topic.where(content: nil) + assert_equal [topic, topic2], Topic.where(content: nil).sort_by(&:id) end def test_nil_is_always_persisted_as_null @@ -347,6 +363,34 @@ class SerializedAttributeTest < ActiveRecord::TestCase topic = model.create!(foo: "bar") topic.foo - refute topic.changed? + assert_not_predicate topic, :changed? + end + + def test_serialized_attribute_works_under_concurrent_initial_access + model = Topic.dup + + topic = model.last + topic.update group: "1" + + model.serialize :group, JSON + model.reset_column_information + + # This isn't strictly necessary for the test, but a little bit of + # knowledge of internals allows us to make failures far more likely. + model.define_singleton_method(:define_attribute) do |*args| + Thread.pass + super(*args) + end + + threads = 4.times.map do + Thread.new do + topic.reload.group + end + end + + # All the threads should retrieve the value knowing it is JSON, and + # thus decode it. If this fails, some threads will instead see the + # raw string ("1"), or raise an exception. + assert_equal [1] * threads.size, threads.map(&:value) end end diff --git a/activerecord/test/cases/statement_cache_test.rb b/activerecord/test/cases/statement_cache_test.rb index fab3648564..e3c12f68fd 100644 --- a/activerecord/test/cases/statement_cache_test.rb +++ b/activerecord/test/cases/statement_cache_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/book" require "models/liquid" @@ -10,7 +12,6 @@ module ActiveRecord @connection = ActiveRecord::Base.connection end - #Cache v 1.1 tests def test_statement_cache Book.create(name: "my book") Book.create(name: "my other book") @@ -19,9 +20,9 @@ module ActiveRecord Book.where(name: params.bind) end - b = cache.execute([ "my book" ], Book, Book.connection) + b = cache.execute([ "my book" ], Book.connection) assert_equal "my book", b[0].name - b = cache.execute([ "my other book" ], Book, Book.connection) + b = cache.execute([ "my other book" ], Book.connection) assert_equal "my other book", b[0].name end @@ -33,9 +34,9 @@ module ActiveRecord Book.where(id: params.bind) end - b = cache.execute([ b1.id ], Book, Book.connection) + b = cache.execute([ b1.id ], Book.connection) assert_equal b1.name, b[0].name - b = cache.execute([ b2.id ], Book, Book.connection) + b = cache.execute([ b2.id ], Book.connection) assert_equal b2.name, b[0].name end @@ -49,8 +50,6 @@ module ActiveRecord assert_equal("my other book", b.name) end - #End - def test_statement_cache_with_simple_statement cache = ActiveRecord::StatementCache.create(Book.connection) do |params| Book.where(name: "my book").where("author_id > 3") @@ -58,7 +57,7 @@ module ActiveRecord Book.create(name: "my book", author_id: 4) - books = cache.execute([], Book, Book.connection) + books = cache.execute([], Book.connection) assert_equal "my book", books[0].name end @@ -71,7 +70,7 @@ module ActiveRecord molecule = salty.molecules.create(name: "dioxane") molecule.electrons.create(name: "lepton") - liquids = cache.execute([], Book, Book.connection) + liquids = cache.execute([], Book.connection) assert_equal "salty", liquids[0].name end @@ -84,13 +83,13 @@ module ActiveRecord Book.create(name: "my book") end - first_books = cache.execute([], Book, Book.connection) + first_books = cache.execute([], Book.connection) 3.times do Book.create(name: "my book") end - additional_books = cache.execute([], Book, Book.connection) + additional_books = cache.execute([], Book.connection) assert first_books != additional_books end @@ -103,7 +102,7 @@ module ActiveRecord Book.find_by(name: "my other book") end - refute_equal book, other_book + assert_not_equal book, other_book end def test_find_by_does_not_use_statement_cache_if_table_name_is_changed diff --git a/activerecord/test/cases/store_test.rb b/activerecord/test/cases/store_test.rb index 633a8a0ebc..4457cfbd37 100644 --- a/activerecord/test/cases/store_test.rb +++ b/activerecord/test/cases/store_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/admin" require "models/admin/user" @@ -6,7 +8,12 @@ class StoreTest < ActiveRecord::TestCase fixtures :'admin/users' setup do - @john = Admin::User.create!(name: "John Doe", color: "black", remember_login: true, height: "tall", is_a_good_guy: true) + @john = Admin::User.create!( + name: "John Doe", color: "black", remember_login: true, + height: "tall", is_a_good_guy: true, + parent_name: "Quinn", partner_name: "Dallas", + partner_birthday: "1997-11-1" + ) end test "reading store attributes through accessors" do @@ -22,6 +29,21 @@ class StoreTest < ActiveRecord::TestCase assert_equal "37signals.com", @john.homepage end + test "reading store attributes through accessors with prefix" do + assert_equal "Quinn", @john.parent_name + assert_nil @john.parent_birthday + assert_equal "Dallas", @john.partner_name + assert_equal "1997-11-1", @john.partner_birthday + end + + test "writing store attributes through accessors with prefix" do + @john.partner_name = "River" + @john.partner_birthday = "1999-2-11" + + assert_equal "River", @john.partner_name + assert_equal "1999-2-11", @john.partner_birthday + end + test "accessing attributes not exposed by accessors" do @john.settings[:icecream] = "graeters" @john.save @@ -43,7 +65,7 @@ class StoreTest < ActiveRecord::TestCase test "updating the store will mark it as changed" do @john.color = "red" - assert @john.settings_changed? + assert_predicate @john, :settings_changed? end test "updating the store populates the changed array correctly" do @@ -54,7 +76,7 @@ class StoreTest < ActiveRecord::TestCase test "updating the store won't mark it as changed if an attribute isn't changed" do @john.color = @john.color - assert !@john.settings_changed? + assert_not_predicate @john, :settings_changed? end test "object initialization with not nullable column" do @@ -135,7 +157,7 @@ class StoreTest < ActiveRecord::TestCase test "updating the store will mark it as changed encoded with JSON" do @john.height = "short" - assert @john.json_data_changed? + assert_predicate @john, :json_data_changed? end test "object initialization with not nullable column encoded with JSON" do @@ -192,4 +214,38 @@ class StoreTest < ActiveRecord::TestCase second_dump = YAML.dump(loaded) assert_equal @john, YAML.load(second_dump) end + + test "read store attributes through accessors with default suffix" do + @john.configs[:two_factor_auth] = true + assert_equal true, @john.two_factor_auth_configs + end + + test "write store attributes through accessors with default suffix" do + @john.two_factor_auth_configs = false + assert_equal false, @john.configs[:two_factor_auth] + end + + test "read store attributes through accessors with custom suffix" do + @john.configs[:login_retry] = 3 + assert_equal 3, @john.login_retry_config + end + + test "write store attributes through accessors with custom suffix" do + @john.login_retry_config = 5 + assert_equal 5, @john.configs[:login_retry] + end + + test "read accessor without pre/suffix in the same store as other pre/suffixed accessors still works" do + @john.configs[:secret_question] = "What is your high school?" + assert_equal "What is your high school?", @john.secret_question + end + + test "write accessor without pre/suffix in the same store as other pre/suffixed accessors still works" do + @john.secret_question = "What was the Rails version when you first worked on it?" + assert_equal "What was the Rails version when you first worked on it?", @john.configs[:secret_question] + end + + test "prefix/suffix do not affect stored attributes" do + assert_equal [:secret_question, :two_factor_auth, :login_retry], Admin::User.stored_attributes[:configs] + end end diff --git a/activerecord/test/cases/suppressor_test.rb b/activerecord/test/cases/suppressor_test.rb index a7d16b7cdb..b68f0033d9 100644 --- a/activerecord/test/cases/suppressor_test.rb +++ b/activerecord/test/cases/suppressor_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/notification" require "models/user" diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 5653fd83fd..d674bd562f 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -1,13 +1,23 @@ +# frozen_string_literal: true + require "cases/helper" require "active_record/tasks/database_tasks" module ActiveRecord module DatabaseTasksSetupper def setup - @mysql_tasks, @postgresql_tasks, @sqlite_tasks = stub, stub, stub - ActiveRecord::Tasks::MySQLDatabaseTasks.stubs(:new).returns @mysql_tasks - ActiveRecord::Tasks::PostgreSQLDatabaseTasks.stubs(:new).returns @postgresql_tasks - ActiveRecord::Tasks::SQLiteDatabaseTasks.stubs(:new).returns @sqlite_tasks + @mysql_tasks, @postgresql_tasks, @sqlite_tasks = Array.new( + 3, + Class.new do + def create; end + def drop; end + def purge; end + def charset; end + def collation; end + def structure_dump(*); end + def structure_load(*); end + end.new + ) $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr @@ -16,6 +26,16 @@ module ActiveRecord def teardown $stdout, $stderr = @original_stdout, @original_stderr end + + def with_stubbed_new + ActiveRecord::Tasks::MySQLDatabaseTasks.stub(:new, @mysql_tasks) do + ActiveRecord::Tasks::PostgreSQLDatabaseTasks.stub(:new, @postgresql_tasks) do + ActiveRecord::Tasks::SQLiteDatabaseTasks.stub(:new, @sqlite_tasks) do + yield + end + end + end + end end ADAPTERS_TASKS = { @@ -26,28 +46,62 @@ module ActiveRecord class DatabaseTasksUtilsTask < ActiveRecord::TestCase def test_raises_an_error_when_called_with_protected_environment - ActiveRecord::Migrator.stubs(:current_version).returns(1) + protected_environments = ActiveRecord::Base.protected_environments + current_env = ActiveRecord::Base.connection.migration_context.current_environment + + assert_called_on_instance_of( + ActiveRecord::MigrationContext, + :current_version, + times: 6, + returns: 1 + ) do + assert_not_includes protected_environments, current_env + # Assert no error + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + + ActiveRecord::Base.protected_environments = [current_env] - protected_environments = ActiveRecord::Base.protected_environments.dup - current_env = ActiveRecord::Migrator.current_environment - assert_not_includes protected_environments, current_env - # Assert no error - ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + assert_raise(ActiveRecord::ProtectedEnvironmentError) do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end + end + ensure + ActiveRecord::Base.protected_environments = protected_environments + end - ActiveRecord::Base.protected_environments << current_env - assert_raise(ActiveRecord::ProtectedEnvironmentError) do + 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 + assert_called_on_instance_of( + ActiveRecord::MigrationContext, + :current_version, + times: 6, + returns: 1 + ) do + assert_not_includes protected_environments, current_env + # Assert no error ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + + ActiveRecord::Base.protected_environments = [current_env.to_sym] + assert_raise(ActiveRecord::ProtectedEnvironmentError) do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end end ensure ActiveRecord::Base.protected_environments = protected_environments end def test_raises_an_error_if_no_migrations_have_been_made - ActiveRecord::InternalMetadata.stubs(:table_exists?).returns(false) - ActiveRecord::Migrator.stubs(:current_version).returns(1) - - assert_raise(ActiveRecord::NoEnvironmentInSchemaError) do - ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + ActiveRecord::InternalMetadata.stub(:table_exists?, false) do + assert_called_on_instance_of( + ActiveRecord::MigrationContext, + :current_version, + returns: 1 + ) do + assert_raise(ActiveRecord::NoEnvironmentInSchemaError) do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end + end end end end @@ -60,11 +114,12 @@ module ActiveRecord end instance = klazz.new - klazz.stubs(:new).returns instance - instance.expects(:structure_dump).with("awesome-file.sql", nil) - - ActiveRecord::Tasks::DatabaseTasks.register_task(/foo/, klazz) - ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => :foo }, "awesome-file.sql") + klazz.stub(:new, instance) do + assert_called_with(instance, :structure_dump, ["awesome-file.sql", nil]) do + ActiveRecord::Tasks::DatabaseTasks.register_task(/foo/, klazz) + ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => :foo }, "awesome-file.sql") + end + end end def test_unregistered_task @@ -79,8 +134,11 @@ module ActiveRecord ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_create") do - eval("@#{v}").expects(:create) - ActiveRecord::Tasks::DatabaseTasks.create "adapter" => k + with_stubbed_new do + assert_called(eval("@#{v}"), :create) do + ActiveRecord::Tasks::DatabaseTasks.create "adapter" => k + end + end end end end @@ -91,6 +149,7 @@ module ActiveRecord ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(ActiveRecord::Base.connection, path) assert File.file?(path) ensure + ActiveRecord::Base.clear_cache! FileUtils.rm_rf(path) end end @@ -99,59 +158,88 @@ module ActiveRecord def setup @configurations = { "development" => { "database" => "my-db" } } - ActiveRecord::Base.stubs(:configurations).returns(@configurations) - # To refrain from connecting to a newly created empty DB in sqlite3_mem tests - ActiveRecord::Base.connection_handler.stubs(:establish_connection) + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr end - def test_ignores_configurations_without_databases - @configurations["development"].merge!("database" => nil) + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end - ActiveRecord::Tasks::DatabaseTasks.expects(:create).never + def test_ignores_configurations_without_databases + @configurations["development"]["database"] = nil - ActiveRecord::Tasks::DatabaseTasks.create_all + with_stubbed_configurations_establish_connection do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end end def test_ignores_remote_databases - @configurations["development"].merge!("host" => "my.server.tld") - $stderr.stubs(:puts).returns(nil) - - ActiveRecord::Tasks::DatabaseTasks.expects(:create).never + @configurations["development"]["host"] = "my.server.tld" - ActiveRecord::Tasks::DatabaseTasks.create_all + with_stubbed_configurations_establish_connection do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end end def test_warning_for_remote_databases - @configurations["development"].merge!("host" => "my.server.tld") + @configurations["development"]["host"] = "my.server.tld" - $stderr.expects(:puts).with("This task only modifies local databases. my-db is on a remote host.") + with_stubbed_configurations_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.create_all - ActiveRecord::Tasks::DatabaseTasks.create_all + assert_match "This task only modifies local databases. my-db is on a remote host.", + $stderr.string + end end def test_creates_configurations_with_local_ip - @configurations["development"].merge!("host" => "127.0.0.1") - - ActiveRecord::Tasks::DatabaseTasks.expects(:create) + @configurations["development"]["host"] = "127.0.0.1" - ActiveRecord::Tasks::DatabaseTasks.create_all + with_stubbed_configurations_establish_connection do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end end def test_creates_configurations_with_local_host - @configurations["development"].merge!("host" => "localhost") - - ActiveRecord::Tasks::DatabaseTasks.expects(:create) + @configurations["development"]["host"] = "localhost" - ActiveRecord::Tasks::DatabaseTasks.create_all + with_stubbed_configurations_establish_connection do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end end def test_creates_configurations_with_blank_hosts - @configurations["development"].merge!("host" => nil) - - ActiveRecord::Tasks::DatabaseTasks.expects(:create) + @configurations["development"]["host"] = nil - ActiveRecord::Tasks::DatabaseTasks.create_all + with_stubbed_configurations_establish_connection do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end end + + private + def with_stubbed_configurations_establish_connection + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + # To refrain from connecting to a newly created empty DB in + # sqlite3_mem tests + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end end class DatabaseTasksCreateCurrentTest < ActiveRecord::TestCase @@ -159,57 +247,211 @@ module ActiveRecord @configurations = { "development" => { "database" => "dev-db" }, "test" => { "database" => "test-db" }, - "production" => { "database" => "prod-db" } + "production" => { "url" => "abstract://prod-db-host/prod-db" } } - - ActiveRecord::Base.stubs(:configurations).returns(@configurations) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_creates_current_environment_database - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "prod-db") + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + ["database" => "test-db"], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("production") - ) + def test_creates_current_environment_database_with_url + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end end def test_creates_test_and_development_databases_when_env_was_not_specified - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "test-db") - - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("development") - ) + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end end def test_creates_test_and_development_databases_when_rails_env_is_development old_env = ENV["RAILS_ENV"] ENV["RAILS_ENV"] = "development" - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "test-db") - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("development") - ) + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end ensure ENV["RAILS_ENV"] = old_env end - def test_establishes_connection_for_the_given_environment - ActiveRecord::Tasks::DatabaseTasks.stubs(:create).returns true + def test_establishes_connection_for_the_given_environments + ActiveRecord::Tasks::DatabaseTasks.stub(:create, nil) do + assert_called_with(ActiveRecord::Base, :establish_connection, [:development]) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end - ActiveRecord::Base.expects(:establish_connection).with(:development) + private + def with_stubbed_configurations_establish_connection + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("development") - ) + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksCreateCurrentThreeTierTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, + "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } + } + end + + def test_creates_current_environment_database + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_creates_current_environment_database_with_url + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_creates_test_and_development_databases_when_env_was_not_specified + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_creates_test_and_development_databases_when_rails_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + def test_establishes_connection_for_the_given_environments_config + ActiveRecord::Tasks::DatabaseTasks.stub(:create, nil) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [:development] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end end + + private + def with_stubbed_configurations_establish_connection + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end end class DatabaseTasksDropTest < ActiveRecord::TestCase @@ -217,8 +459,11 @@ module ActiveRecord ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_drop") do - eval("@#{v}").expects(:drop) - ActiveRecord::Tasks::DatabaseTasks.drop "adapter" => k + with_stubbed_new do + assert_called(eval("@#{v}"), :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop "adapter" => k + end + end end end end @@ -227,57 +472,84 @@ module ActiveRecord def setup @configurations = { development: { "database" => "my-db" } } - ActiveRecord::Base.stubs(:configurations).returns(@configurations) + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr end - def test_ignores_configurations_without_databases - @configurations[:development].merge!("database" => nil) + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end - ActiveRecord::Tasks::DatabaseTasks.expects(:drop).never + def test_ignores_configurations_without_databases + @configurations[:development]["database"] = nil - ActiveRecord::Tasks::DatabaseTasks.drop_all + with_stubbed_configurations do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end end def test_ignores_remote_databases - @configurations[:development].merge!("host" => "my.server.tld") - $stderr.stubs(:puts).returns(nil) - - ActiveRecord::Tasks::DatabaseTasks.expects(:drop).never + @configurations[:development]["host"] = "my.server.tld" - ActiveRecord::Tasks::DatabaseTasks.drop_all + with_stubbed_configurations do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end end def test_warning_for_remote_databases - @configurations[:development].merge!("host" => "my.server.tld") + @configurations[:development]["host"] = "my.server.tld" - $stderr.expects(:puts).with("This task only modifies local databases. my-db is on a remote host.") + with_stubbed_configurations do + ActiveRecord::Tasks::DatabaseTasks.drop_all - ActiveRecord::Tasks::DatabaseTasks.drop_all + assert_match "This task only modifies local databases. my-db is on a remote host.", + $stderr.string + end end def test_drops_configurations_with_local_ip - @configurations[:development].merge!("host" => "127.0.0.1") - - ActiveRecord::Tasks::DatabaseTasks.expects(:drop) + @configurations[:development]["host"] = "127.0.0.1" - ActiveRecord::Tasks::DatabaseTasks.drop_all + with_stubbed_configurations do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end end def test_drops_configurations_with_local_host - @configurations[:development].merge!("host" => "localhost") - - ActiveRecord::Tasks::DatabaseTasks.expects(:drop) + @configurations[:development]["host"] = "localhost" - ActiveRecord::Tasks::DatabaseTasks.drop_all + with_stubbed_configurations do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end end def test_drops_configurations_with_blank_hosts - @configurations[:development].merge!("host" => nil) - - ActiveRecord::Tasks::DatabaseTasks.expects(:drop) + @configurations[:development]["host"] = nil - ActiveRecord::Tasks::DatabaseTasks.drop_all + with_stubbed_configurations do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end end class DatabaseTasksDropCurrentTest < ActiveRecord::TestCase @@ -285,74 +557,311 @@ module ActiveRecord @configurations = { "development" => { "database" => "dev-db" }, "test" => { "database" => "test-db" }, - "production" => { "database" => "prod-db" } + "production" => { "url" => "abstract://prod-db-host/prod-db" } } - - ActiveRecord::Base.stubs(:configurations).returns(@configurations) end def test_drops_current_environment_database - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "prod-db") + with_stubbed_configurations do + assert_called_with(ActiveRecord::Tasks::DatabaseTasks, :drop, + ["database" => "test-db"]) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("production") - ) + def test_drops_current_environment_database_with_url + with_stubbed_configurations do + assert_called_with(ActiveRecord::Tasks::DatabaseTasks, :drop, + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"]) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end end def test_drops_test_and_development_databases_when_env_was_not_specified - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "test-db") - - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("development") - ) + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end end def test_drops_testand_development_databases_when_rails_env_is_development old_env = ENV["RAILS_ENV"] ENV["RAILS_ENV"] = "development" - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "test-db") - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("development") - ) + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end ensure ENV["RAILS_ENV"] = old_env end - end - class DatabaseTasksMigrateTest < ActiveRecord::TestCase - self.use_transactional_tests = false + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksDropCurrentThreeTierTest < ActiveRecord::TestCase def setup - ActiveRecord::Tasks::DatabaseTasks.migrations_paths = "custom/path" + @configurations = { + "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, + "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } + } end - def teardown - ActiveRecord::Tasks::DatabaseTasks.migrations_paths = nil + def test_drops_current_environment_database + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end end - def test_migrate_receives_correct_env_vars - verbose, version = ENV["VERBOSE"], ENV["VERSION"] + def test_drops_current_environment_database_with_url + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_drops_test_and_development_databases_when_env_was_not_specified + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end - ENV["VERBOSE"] = "false" - ENV["VERSION"] = "4" + def test_drops_testand_development_databases_when_rails_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" - ActiveRecord::Migrator.expects(:migrate).with("custom/path", 4) - ActiveRecord::Tasks::DatabaseTasks.migrate + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end ensure - ENV["VERBOSE"], ENV["VERSION"] = verbose, version + ENV["RAILS_ENV"] = old_env + end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + if current_adapter?(:SQLite3Adapter) && !in_memory_db? + class DatabaseTasksMigrateTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + # Use a memory db here to avoid having to rollback at the end + setup do + migrations_path = MIGRATIONS_ROOT + "/valid" + file = ActiveRecord::Base.connection.raw_connection.filename + @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", + database: ":memory:", migrations_paths: migrations_path + source_db = SQLite3::Database.new file + dest_db = ActiveRecord::Base.connection.raw_connection + backup = SQLite3::Backup.new(dest_db, "main", source_db, "main") + backup.step(-1) + backup.finish + end + + teardown do + @conn.release_connection if @conn + ActiveRecord::Base.establish_connection :arunit + end + + def test_migrate_set_and_unset_verbose_and_version_env_vars + verbose, version = ENV["VERBOSE"], ENV["VERSION"] + ENV["VERSION"] = "2" + ENV["VERBOSE"] = "false" + + # run down migration because it was already run on copied db + assert_empty capture_migration_output + + ENV.delete("VERSION") + ENV.delete("VERBOSE") + + # re-run up migration + assert_includes capture_migration_output, "migrating" + ensure + ENV["VERBOSE"], ENV["VERSION"] = verbose, version + end + + def test_migrate_set_and_unset_empty_values_for_verbose_and_version_env_vars + verbose, version = ENV["VERBOSE"], ENV["VERSION"] + + ENV["VERSION"] = "2" + ENV["VERBOSE"] = "false" + + # run down migration because it was already run on copied db + assert_empty capture_migration_output + + ENV["VERBOSE"] = "" + ENV["VERSION"] = "" + + # re-run up migration + assert_includes capture_migration_output, "migrating" + ensure + ENV["VERBOSE"], ENV["VERSION"] = verbose, version + end + + def test_migrate_set_and_unset_nonsense_values_for_verbose_and_version_env_vars + verbose, version = ENV["VERBOSE"], ENV["VERSION"] + + # run down migration because it was already run on copied db + ENV["VERSION"] = "2" + ENV["VERBOSE"] = "false" + + assert_empty capture_migration_output + + ENV["VERBOSE"] = "yes" + ENV["VERSION"] = "2" + + # run no migration because 2 was already run + assert_empty capture_migration_output + ensure + ENV["VERBOSE"], ENV["VERSION"] = verbose, version + end + + private + def capture_migration_output + capture(:stdout) do + ActiveRecord::Tasks::DatabaseTasks.migrate + end + end + end + end + + class DatabaseTasksMigrateErrorTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + def test_migrate_raise_error_on_invalid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "unknown" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0 " + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1." + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_name" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + ensure + ENV["VERSION"] = version + end + + def test_migrate_raise_error_on_failed_check_target_version + ActiveRecord::Tasks::DatabaseTasks.stub(:check_target_version, -> { raise "foo" }) do + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_equal "foo", e.message + end end def test_migrate_clears_schema_cache_afterward - ActiveRecord::Base.expects(:clear_cache!) - ActiveRecord::Tasks::DatabaseTasks.migrate + assert_called(ActiveRecord::Base, :clear_cache!) do + ActiveRecord::Tasks::DatabaseTasks.migrate + end end end @@ -361,38 +870,55 @@ module ActiveRecord ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_purge") do - eval("@#{v}").expects(:purge) - ActiveRecord::Tasks::DatabaseTasks.purge "adapter" => k + with_stubbed_new do + assert_called(eval("@#{v}"), :purge) do + ActiveRecord::Tasks::DatabaseTasks.purge "adapter" => k + end + end end end end class DatabaseTasksPurgeCurrentTest < ActiveRecord::TestCase def test_purges_current_environment_database + old_configurations = ActiveRecord::Base.configurations configurations = { "development" => { "database" => "dev-db" }, "test" => { "database" => "test-db" }, "production" => { "database" => "prod-db" } } - ActiveRecord::Base.stubs(:configurations).returns(configurations) - ActiveRecord::Tasks::DatabaseTasks.expects(:purge). - with("database" => "prod-db") - ActiveRecord::Base.expects(:establish_connection).with(:production) + ActiveRecord::Base.configurations = configurations - ActiveRecord::Tasks::DatabaseTasks.purge_current("production") + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :purge, + ["database" => "prod-db"] + ) do + assert_called_with(ActiveRecord::Base, :establish_connection, [:production]) do + ActiveRecord::Tasks::DatabaseTasks.purge_current("production") + end + end + ensure + ActiveRecord::Base.configurations = old_configurations end end class DatabaseTasksPurgeAllTest < ActiveRecord::TestCase def test_purge_all_local_configurations + old_configurations = ActiveRecord::Base.configurations configurations = { development: { "database" => "my-db" } } - ActiveRecord::Base.stubs(:configurations).returns(configurations) - - ActiveRecord::Tasks::DatabaseTasks.expects(:purge). - with("database" => "my-db") - - ActiveRecord::Tasks::DatabaseTasks.purge_all + ActiveRecord::Base.configurations = configurations + + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :purge, + ["database" => "my-db"] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge_all + end + ensure + ActiveRecord::Base.configurations = old_configurations end end @@ -401,8 +927,11 @@ module ActiveRecord ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_charset") do - eval("@#{v}").expects(:charset) - ActiveRecord::Tasks::DatabaseTasks.charset "adapter" => k + with_stubbed_new do + assert_called(eval("@#{v}"), :charset) do + ActiveRecord::Tasks::DatabaseTasks.charset "adapter" => k + end + end end end end @@ -412,19 +941,130 @@ module ActiveRecord ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_collation") do - eval("@#{v}").expects(:collation) - ActiveRecord::Tasks::DatabaseTasks.collation "adapter" => k + with_stubbed_new do + assert_called(eval("@#{v}"), :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation "adapter" => k + end + end end end end + class DatabaseTaskTargetVersionTest < ActiveRecord::TestCase + def test_target_version_returns_nil_if_version_does_not_exist + version = ENV.delete("VERSION") + assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + + def test_target_version_returns_nil_if_version_is_empty + version = ENV["VERSION"] + + ENV["VERSION"] = "" + assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + + def test_target_version_returns_converted_to_integer_env_version_if_version_exists + version = ENV["VERSION"] + + ENV["VERSION"] = "0" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + + ENV["VERSION"] = "42" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + + ENV["VERSION"] = "042" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + end + + class DatabaseTaskCheckTargetVersionTest < ActiveRecord::TestCase + def test_check_target_version_does_not_raise_error_on_empty_version + version = ENV["VERSION"] + ENV["VERSION"] = "" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_does_not_raise_error_if_version_is_not_setted + version = ENV.delete("VERSION") + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_raises_error_on_invalid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "unknown" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0 " + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1." + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_name" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_does_not_raise_error_on_valid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "0" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "1" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "001" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "001_name.rb" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + end + class DatabaseTasksStructureDumpTest < ActiveRecord::TestCase include DatabaseTasksSetupper ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_structure_dump") do - eval("@#{v}").expects(:structure_dump).with("awesome-file.sql", nil) - ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => k }, "awesome-file.sql") + with_stubbed_new do + assert_called_with( + eval("@#{v}"), :structure_dump, + ["awesome-file.sql", nil] + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => k }, "awesome-file.sql") + end + end end end end @@ -434,31 +1074,41 @@ module ActiveRecord ADAPTERS_TASKS.each do |k, v| define_method("test_#{k}_structure_load") do - eval("@#{v}").expects(:structure_load).with("awesome-file.sql", nil) - ActiveRecord::Tasks::DatabaseTasks.structure_load({ "adapter" => k }, "awesome-file.sql") + with_stubbed_new do + assert_called_with( + eval("@#{v}"), + :structure_load, + ["awesome-file.sql", nil] + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load({ "adapter" => k }, "awesome-file.sql") + end + end end end end class DatabaseTasksCheckSchemaFileTest < ActiveRecord::TestCase def test_check_schema_file - Kernel.expects(:abort).with(regexp_matches(/awesome-file.sql/)) - ActiveRecord::Tasks::DatabaseTasks.check_schema_file("awesome-file.sql") + assert_called_with(Kernel, :abort, [/awesome-file.sql/]) do + ActiveRecord::Tasks::DatabaseTasks.check_schema_file("awesome-file.sql") + end end end class DatabaseTasksCheckSchemaFileDefaultsTest < ActiveRecord::TestCase def test_check_schema_file_defaults - ActiveRecord::Tasks::DatabaseTasks.stubs(:db_dir).returns("/tmp") - assert_equal "/tmp/schema.rb", ActiveRecord::Tasks::DatabaseTasks.schema_file + ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/tmp") do + assert_equal "/tmp/schema.rb", ActiveRecord::Tasks::DatabaseTasks.schema_file + end end end class DatabaseTasksCheckSchemaFileSpecifiedFormatsTest < ActiveRecord::TestCase { ruby: "schema.rb", sql: "structure.sql" }.each_pair do |fmt, filename| define_method("test_check_schema_file_for_#{fmt}_format") do - ActiveRecord::Tasks::DatabaseTasks.stubs(:db_dir).returns("/tmp") - assert_equal "/tmp/#{filename}", ActiveRecord::Tasks::DatabaseTasks.schema_file(fmt) + ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/tmp") do + assert_equal "/tmp/#{filename}", ActiveRecord::Tasks::DatabaseTasks.schema_file(fmt) + end end end end diff --git a/activerecord/test/cases/tasks/mysql_rake_test.rb b/activerecord/test/cases/tasks/mysql_rake_test.rb index f30e0958c3..4d6dff68f9 100644 --- a/activerecord/test/cases/tasks/mysql_rake_test.rb +++ b/activerecord/test/cases/tasks/mysql_rake_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_record/tasks/database_tasks" @@ -5,15 +7,11 @@ if current_adapter?(:Mysql2Adapter) module ActiveRecord class MysqlDBCreateTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true) + @connection = Class.new { def create_database(*); end }.new @configuration = { "adapter" => "mysql2", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -23,59 +21,97 @@ if current_adapter?(:Mysql2Adapter) end def test_establishes_connection_without_database - ActiveRecord::Base.expects(:establish_connection). - with("adapter" => "mysql2", "database" => nil) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ "adapter" => "mysql2", "database" => nil ], + [ "adapter" => "mysql2", "database" => "my-app-db" ], + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end end def test_creates_database_with_no_default_options - @connection.expects(:create_database). - with("my-app-db", {}) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :create_database, ["my-app-db", {}]) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end end def test_creates_database_with_given_encoding - @connection.expects(:create_database). - with("my-app-db", charset: "latin1") - - ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("encoding" => "latin1") + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :create_database, ["my-app-db", charset: "latin1"]) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("encoding" => "latin1") + end + end end def test_creates_database_with_given_collation - @connection.expects(:create_database). - with("my-app-db", collation: "latin1_swedish_ci") - - ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("collation" => "latin1_swedish_ci") + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + ["my-app-db", collation: "latin1_swedish_ci"] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("collation" => "latin1_swedish_ci") + end + end end def test_establishes_connection_to_database - ActiveRecord::Base.expects(:establish_connection).with(@configuration) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + ["adapter" => "mysql2", "database" => nil], + [@configuration] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end end def test_when_database_created_successfully_outputs_info_to_stdout - ActiveRecord::Tasks::DatabaseTasks.create @configuration + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.create @configuration - assert_equal "Created database 'my-app-db'\n", $stdout.string + assert_equal "Created database 'my-app-db'\n", $stdout.string + end end def test_create_when_database_exists_outputs_info_to_stderr - ActiveRecord::Base.connection.stubs(:create_database).raises( - ActiveRecord::Tasks::DatabaseAlreadyExists - ) + with_stubbed_connection_establish_connection do + ActiveRecord::Base.connection.stub( + :create_database, + proc { raise ActiveRecord::Tasks::DatabaseAlreadyExists } + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + + assert_equal "Database 'my-app-db' already exists\n", $stderr.string + end + end + end - ActiveRecord::Tasks::DatabaseTasks.create @configuration + private - assert_equal "Database 'my-app-db' already exists\n", $stderr.string - end + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end end - class MysqlDBCreateAsRootTest < ActiveRecord::TestCase + class MysqlDBCreateWithInvalidPermissionsTest < ActiveRecord::TestCase def setup - @connection = stub("Connection", create_database: true) @error = Mysql2::Error.new("Invalid permissions") @configuration = { "adapter" => "mysql2", @@ -83,15 +119,6 @@ if current_adapter?(:Mysql2Adapter) "username" => "pat", "password" => "wossname" } - - $stdin.stubs(:gets).returns("secret\n") - $stdout.stubs(:print).returns(nil) - @error.stubs(:errno).returns(1045) - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection). - raises(@error). - then.returns(true) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -100,88 +127,22 @@ if current_adapter?(:Mysql2Adapter) $stdout, $stderr = @original_stdout, @original_stderr end - def test_root_password_is_requested - assert_permissions_granted_for("pat") - $stdin.expects(:gets).returns("secret\n") - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_connection_established_as_root - assert_permissions_granted_for("pat") - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "mysql2", - "database" => nil, - "username" => "root", - "password" => "secret" - ) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_database_created_by_root - assert_permissions_granted_for("pat") - @connection.expects(:create_database). - with("my-app-db", {}) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_grant_privileges_for_normal_user - assert_permissions_granted_for("pat") - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_do_not_grant_privileges_for_root_user - @configuration["username"] = "root" - @configuration["password"] = "" - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_connection_established_as_normal_user - assert_permissions_granted_for("pat") - ActiveRecord::Base.expects(:establish_connection).returns do - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "mysql2", - "database" => "my-app-db", - "username" => "pat", - "password" => "secret" - ) - - raise @error + def test_raises_error + ActiveRecord::Base.stub(:establish_connection, -> * { raise @error }) do + assert_raises(Mysql2::Error, "Invalid permissions") do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end end - - ActiveRecord::Tasks::DatabaseTasks.create @configuration end - - def test_sends_output_to_stderr_when_other_errors - @error.stubs(:errno).returns(42) - - $stderr.expects(:puts).at_least_once.returns(nil) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - private - - def assert_permissions_granted_for(db_user) - db_name = @configuration["database"] - db_password = @configuration["password"] - @connection.expects(:execute).with("GRANT ALL PRIVILEGES ON #{db_name}.* TO '#{db_user}'@'localhost' IDENTIFIED BY '#{db_password}' WITH GRANT OPTION;") - end end class MySQLDBDropTest < ActiveRecord::TestCase def setup - @connection = stub(drop_database: true) + @connection = Class.new { def drop_database(name); end }.new @configuration = { "adapter" => "mysql2", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -191,91 +152,130 @@ if current_adapter?(:Mysql2Adapter) end def test_establishes_connection_to_mysql_database - ActiveRecord::Base.expects(:establish_connection).with @configuration - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [@configuration] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end end def test_drops_database - @connection.expects(:drop_database).with("my-app-db") - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :drop_database, ["my-app-db"]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end end def test_when_database_dropped_successfully_outputs_info_to_stdout - ActiveRecord::Tasks::DatabaseTasks.drop @configuration + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration - assert_equal "Dropped database 'my-app-db'\n", $stdout.string + assert_equal "Dropped database 'my-app-db'\n", $stdout.string + end end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end end class MySQLPurgeTest < ActiveRecord::TestCase def setup - @connection = stub(recreate_database: true) + @connection = Class.new { def recreate_database(*); end }.new @configuration = { "adapter" => "mysql2", "database" => "test-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_establishes_connection_to_the_appropriate_database - ActiveRecord::Base.expects(:establish_connection).with(@configuration) - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [@configuration] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end end def test_recreates_database_with_no_default_options - @connection.expects(:recreate_database). - with("test-db", {}) - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :recreate_database, ["test-db", {}]) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end end def test_recreates_database_with_the_given_options - @connection.expects(:recreate_database). - with("test-db", charset: "latin", collation: "latin1_swedish_ci") - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration.merge( - "encoding" => "latin", "collation" => "latin1_swedish_ci") + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :recreate_database, + ["test-db", charset: "latin", collation: "latin1_swedish_ci"] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration.merge( + "encoding" => "latin", "collation" => "latin1_swedish_ci") + end + end end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end end class MysqlDBCharsetTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true) + @connection = Class.new { def charset; end }.new @configuration = { "adapter" => "mysql2", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_db_retrieves_charset - @connection.expects(:charset) - ActiveRecord::Tasks::DatabaseTasks.charset @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :charset) do + ActiveRecord::Tasks::DatabaseTasks.charset @configuration + end + end end end class MysqlDBCollationTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true) + @connection = Class.new { def collation; end }.new @configuration = { "adapter" => "mysql2", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_db_retrieves_collation - @connection.expects(:collation) - ActiveRecord::Tasks::DatabaseTasks.collation @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with(@connection, :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end end end @@ -289,14 +289,19 @@ if current_adapter?(:Mysql2Adapter) def test_structure_dump filename = "awesome-file.sql" - Kernel.expects(:system).with("mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db").returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + assert_called_with( + Kernel, + :system, + ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end end def test_structure_dump_with_extra_flags filename = "awesome-file.sql" - expected_command = ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "--noop", "test-db"] + expected_command = ["mysqldump", "--noop", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"] assert_called_with(Kernel, :system, expected_command, returns: true) do with_structure_dump_flags(["--noop"]) do @@ -305,34 +310,61 @@ if current_adapter?(:Mysql2Adapter) end end - def test_warn_when_external_structure_dump_command_execution_fails + def test_structure_dump_with_ignore_tables filename = "awesome-file.sql" - Kernel.expects(:system) - .with("mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db") - .returns(false) + ActiveRecord::SchemaDumper.stub(:ignore_tables, ["foo", "bar"]) do + assert_called_with( + Kernel, + :system, + ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "--ignore-table=test-db.foo", "--ignore-table=test-db.bar", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + end + end - e = assert_raise(RuntimeError) { - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) - } - assert_match(/^failed to execute: `mysqldump`$/, e.message) + def test_warn_when_external_structure_dump_command_execution_fails + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: false + ) do + e = assert_raise(RuntimeError) { + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + } + assert_match(/^failed to execute: `mysqldump`$/, e.message) + end end def test_structure_dump_with_port_number filename = "awesome-file.sql" - Kernel.expects(:system).with("mysqldump", "--port=10000", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db").returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump( - @configuration.merge("port" => 10000), - filename) + assert_called_with( + Kernel, + :system, + ["mysqldump", "--port=10000", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump( + @configuration.merge("port" => 10000), + filename) + end end def test_structure_dump_with_ssl filename = "awesome-file.sql" - Kernel.expects(:system).with("mysqldump", "--ssl-ca=ca.crt", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db").returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump( - @configuration.merge("sslca" => "ca.crt"), - filename) + assert_called_with( + Kernel, + :system, + ["mysqldump", "--ssl-ca=ca.crt", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump( + @configuration.merge("sslca" => "ca.crt"), + filename) + end end private @@ -355,7 +387,7 @@ if current_adapter?(:Mysql2Adapter) def test_structure_load filename = "awesome-file.sql" - expected_command = ["mysql", "--execute", %{SET FOREIGN_KEY_CHECKS = 0; SOURCE #{filename}; SET FOREIGN_KEY_CHECKS = 1}, "--database", "test-db", "--noop"] + expected_command = ["mysql", "--noop", "--execute", %{SET FOREIGN_KEY_CHECKS = 0; SOURCE #{filename}; SET FOREIGN_KEY_CHECKS = 1}, "--database", "test-db"] assert_called_with(Kernel, :system, expected_command, returns: true) do with_structure_load_flags(["--noop"]) do diff --git a/activerecord/test/cases/tasks/postgresql_rake_test.rb b/activerecord/test/cases/tasks/postgresql_rake_test.rb index a23100c32a..8c6e8d79f3 100644 --- a/activerecord/test/cases/tasks/postgresql_rake_test.rb +++ b/activerecord/test/cases/tasks/postgresql_rake_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_record/tasks/database_tasks" @@ -5,15 +7,11 @@ if current_adapter?(:PostgreSQLAdapter) module ActiveRecord class PostgreSQLDBCreateTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true) + @connection = Class.new { def create_database(*); end }.new @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -23,82 +21,141 @@ if current_adapter?(:PostgreSQLAdapter) end def test_establishes_connection_to_postgresql_database - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "postgresql", - "database" => "postgres", - "schema_search_path" => "public" - ) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + "adapter" => "postgresql", + "database" => "my-app-db" + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end end def test_creates_database_with_default_encoding - @connection.expects(:create_database). - with("my-app-db", @configuration.merge("encoding" => "utf8")) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + ["my-app-db", @configuration.merge("encoding" => "utf8")] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end end def test_creates_database_with_given_encoding - @connection.expects(:create_database). - with("my-app-db", @configuration.merge("encoding" => "latin")) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration. - merge("encoding" => "latin") + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + ["my-app-db", @configuration.merge("encoding" => "latin")] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration. + merge("encoding" => "latin") + end + end end def test_creates_database_with_given_collation_and_ctype - @connection.expects(:create_database). - with("my-app-db", @configuration.merge("encoding" => "utf8", "collation" => "ja_JP.UTF8", "ctype" => "ja_JP.UTF8")) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration. - merge("collation" => "ja_JP.UTF8", "ctype" => "ja_JP.UTF8") + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + [ + "my-app-db", + @configuration.merge( + "encoding" => "utf8", + "collation" => "ja_JP.UTF8", + "ctype" => "ja_JP.UTF8" + ) + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration. + merge("collation" => "ja_JP.UTF8", "ctype" => "ja_JP.UTF8") + end + end end def test_establishes_connection_to_new_database - ActiveRecord::Base.expects(:establish_connection).with(@configuration) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + @configuration + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end end def test_db_create_with_error_prints_message - ActiveRecord::Base.stubs(:establish_connection).raises(Exception) - - $stderr.stubs(:puts).returns(true) - $stderr.expects(:puts). - with("Couldn't create database for #{@configuration.inspect}") - - assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration } + ActiveRecord::Base.stub(:connection, @connection) do + ActiveRecord::Base.stub(:establish_connection, -> * { raise Exception }) do + assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration } + assert_match "Couldn't create '#{@configuration['database']}' database. Please check your configuration.", $stderr.string + end + end end def test_when_database_created_successfully_outputs_info_to_stdout - ActiveRecord::Tasks::DatabaseTasks.create @configuration + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.create @configuration - assert_equal "Created database 'my-app-db'\n", $stdout.string + assert_equal "Created database 'my-app-db'\n", $stdout.string + end end def test_create_when_database_exists_outputs_info_to_stderr - ActiveRecord::Base.connection.stubs(:create_database).raises( - ActiveRecord::Tasks::DatabaseAlreadyExists - ) + with_stubbed_connection_establish_connection do + ActiveRecord::Base.connection.stub( + :create_database, + proc { raise ActiveRecord::Tasks::DatabaseAlreadyExists } + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + + assert_equal "Database 'my-app-db' already exists\n", $stderr.string + end + end + end - ActiveRecord::Tasks::DatabaseTasks.create @configuration + private - assert_equal "Database 'my-app-db' already exists\n", $stderr.string - end + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:connection, @connection) do + ActiveRecord::Base.stub(:establish_connection, nil) do + yield + end + end + end end class PostgreSQLDBDropTest < ActiveRecord::TestCase def setup - @connection = stub(drop_database: true) + @connection = Class.new { def drop_database(*); end }.new @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -108,132 +165,221 @@ if current_adapter?(:PostgreSQLAdapter) end def test_establishes_connection_to_postgresql_database - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "postgresql", - "database" => "postgres", - "schema_search_path" => "public" - ) - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end end def test_drops_database - @connection.expects(:drop_database).with("my-app-db") - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :drop_database, + ["my-app-db"] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end end def test_when_database_dropped_successfully_outputs_info_to_stdout - ActiveRecord::Tasks::DatabaseTasks.drop @configuration + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration - assert_equal "Dropped database 'my-app-db'\n", $stdout.string + assert_equal "Dropped database 'my-app-db'\n", $stdout.string + end end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:connection, @connection) do + ActiveRecord::Base.stub(:establish_connection, nil) do + yield + end + end + end end class PostgreSQLPurgeTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true, drop_database: true) + @connection = Class.new do + def create_database(*); end + def drop_database(*); end + end.new @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:clear_active_connections!).returns(true) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_clears_active_connections - ActiveRecord::Base.expects(:clear_active_connections!) - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + with_stubbed_connection do + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called(ActiveRecord::Base, :clear_active_connections!) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end end def test_establishes_connection_to_postgresql_database - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "postgresql", - "database" => "postgres", - "schema_search_path" => "public" - ) - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + with_stubbed_connection do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + "adapter" => "postgresql", + "database" => "my-app-db" + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end end def test_drops_database - @connection.expects(:drop_database).with("my-app-db") - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + with_stubbed_connection do + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called_with(@connection, :drop_database, ["my-app-db"]) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end end def test_creates_database - @connection.expects(:create_database). - with("my-app-db", @configuration.merge("encoding" => "utf8")) - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + with_stubbed_connection do + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called_with( + @connection, + :create_database, + ["my-app-db", @configuration.merge("encoding" => "utf8")] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end end def test_establishes_connection - ActiveRecord::Base.expects(:establish_connection).with(@configuration) - - ActiveRecord::Tasks::DatabaseTasks.purge @configuration + with_stubbed_connection do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + @configuration + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end end + + private + + def with_stubbed_connection + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end end class PostgreSQLDBCharsetTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true) + @connection = Class.new do + def create_database(*); end + def encoding; end + end.new @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_db_retrieves_charset - @connection.expects(:encoding) - ActiveRecord::Tasks::DatabaseTasks.charset @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :encoding) do + ActiveRecord::Tasks::DatabaseTasks.charset @configuration + end + end end end class PostgreSQLDBCollationTest < ActiveRecord::TestCase def setup - @connection = stub(create_database: true) + @connection = Class.new { def collation; end }.new @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_db_retrieves_collation - @connection.expects(:collation) - ActiveRecord::Tasks::DatabaseTasks.collation @configuration + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end end end class PostgreSQLStructureDumpTest < ActiveRecord::TestCase def setup - @connection = stub(structure_dump: true) @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - @filename = "awesome-file.sql" + @filename = "/tmp/awesome-file.sql" + FileUtils.touch(@filename) + end - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - Kernel.stubs(:system) - File.stubs(:open) + def teardown + FileUtils.rm_f(@filename) end def test_structure_dump - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db").returns(true) + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + + def test_structure_dump_header_comments_removed + Kernel.stub(:system, true) do + File.write(@filename, "-- header comment\n\n-- more header comment\n statement \n-- lower comment\n") + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + assert_equal [" statement \n", "-- lower comment\n"], File.readlines(@filename).first(2) + end end def test_structure_dump_with_extra_flags @@ -246,29 +392,76 @@ if current_adapter?(:PostgreSQLAdapter) end end + def test_structure_dump_with_ignore_tables + assert_called( + ActiveRecord::SchemaDumper, + :ignore_tables, + returns: ["foo", "bar"] + ) do + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "-T", "foo", "-T", "bar", "my-app-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + end + def test_structure_dump_with_schema_search_path @configuration["schema_search_path"] = "foo,bar" - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db").returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end end def test_structure_dump_with_schema_search_path_and_dump_schemas_all @configuration["schema_search_path"] = "foo,bar" - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db").returns(true) - - with_dump_schemas(:all) do - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db"], + returns: true + ) do + with_dump_schemas(:all) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end end end def test_structure_dump_with_dump_schemas_string - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db").returns(true) + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db"], + returns: true + ) do + with_dump_schemas("foo,bar") do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + end - with_dump_schemas("foo,bar") do - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + def test_structure_dump_execution_fails + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", filename, "my-app-db"], + returns: nil + ) do + e = assert_raise(RuntimeError) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + assert_match("failed to execute:", e.message) end end @@ -292,22 +485,22 @@ if current_adapter?(:PostgreSQLAdapter) class PostgreSQLStructureLoadTest < ActiveRecord::TestCase def setup - @connection = stub @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - Kernel.stubs(:system) end def test_structure_load filename = "awesome-file.sql" - Kernel.expects(:system).with("psql", "-v", "ON_ERROR_STOP=1", "-q", "-f", filename, @configuration["database"]).returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + assert_called_with( + Kernel, + :system, + ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-f", filename, @configuration["database"]], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end end def test_structure_load_with_extra_flags @@ -323,9 +516,14 @@ if current_adapter?(:PostgreSQLAdapter) def test_structure_load_accepts_path_with_spaces filename = "awesome file.sql" - Kernel.expects(:system).with("psql", "-v", "ON_ERROR_STOP=1", "-q", "-f", filename, @configuration["database"]).returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + assert_called_with( + Kernel, + :system, + ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-f", filename, @configuration["database"]], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end end private diff --git a/activerecord/test/cases/tasks/sqlite_rake_test.rb b/activerecord/test/cases/tasks/sqlite_rake_test.rb index 0d917f3f6c..c1092b97c1 100644 --- a/activerecord/test/cases/tasks/sqlite_rake_test.rb +++ b/activerecord/test/cases/tasks/sqlite_rake_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "active_record/tasks/database_tasks" require "pathname" @@ -7,16 +9,10 @@ if current_adapter?(:SQLite3Adapter) class SqliteDBCreateTest < ActiveRecord::TestCase def setup @database = "db_create.sqlite3" - @connection = stub :connection @configuration = { "adapter" => "sqlite3", "database" => @database } - - File.stubs(:exist?).returns(false) - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -26,63 +22,62 @@ if current_adapter?(:SQLite3Adapter) end def test_db_checks_database_exists - File.expects(:exist?).with(@database).returns(false) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called_with(File, :exist?, [@database], returns: false) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end + end end def test_when_db_created_successfully_outputs_info_to_stdout - ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" - assert_equal "Created database '#{@database}'\n", $stdout.string + assert_equal "Created database '#{@database}'\n", $stdout.string + end end def test_db_create_when_file_exists - File.stubs(:exist?).returns(true) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + File.stub(:exist?, true) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" - assert_equal "Database '#{@database}' already exists\n", $stderr.string + assert_equal "Database '#{@database}' already exists\n", $stderr.string + end end def test_db_create_with_file_does_nothing - File.stubs(:exist?).returns(true) - $stderr.stubs(:puts).returns(nil) - - ActiveRecord::Base.expects(:establish_connection).never - - ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + File.stub(:exist?, true) do + assert_not_called(ActiveRecord::Base, :establish_connection) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end + end end def test_db_create_establishes_a_connection - ActiveRecord::Base.expects(:establish_connection).with(@configuration) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + assert_called_with(ActiveRecord::Base, :establish_connection, [@configuration]) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end end def test_db_create_with_error_prints_message - ActiveRecord::Base.stubs(:establish_connection).raises(Exception) - - $stderr.stubs(:puts).returns(true) - $stderr.expects(:puts). - with("Couldn't create database for #{@configuration.inspect}") - - assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" } + ActiveRecord::Base.stub(:establish_connection, proc { raise Exception }) do + assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" } + assert_match "Couldn't create '#{@configuration['database']}' database. Please check your configuration.", $stderr.string + end end end class SqliteDBDropTest < ActiveRecord::TestCase def setup @database = "db_create.sqlite3" - @path = stub(to_s: "/absolute/path", absolute?: true) @configuration = { "adapter" => "sqlite3", "database" => @database } - - Pathname.stubs(:new).returns(@path) - File.stubs(:join).returns("/former/relative/path") - FileUtils.stubs(:rm).returns(true) + @path = Class.new do + def to_s; "/absolute/path" end + def absolute?; true end + end.new $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr @@ -93,77 +88,76 @@ if current_adapter?(:SQLite3Adapter) end def test_creates_path_from_database - Pathname.expects(:new).with(@database).returns(@path) - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + assert_called_with(Pathname, :new, [@database], returns: @path) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end end def test_removes_file_with_absolute_path - File.stubs(:exist?).returns(true) - @path.stubs(:absolute?).returns(true) - - FileUtils.expects(:rm).with("/absolute/path") - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + Pathname.stub(:new, @path) do + assert_called_with(FileUtils, :rm, ["/absolute/path"]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end end def test_generates_absolute_path_with_given_root - @path.stubs(:absolute?).returns(false) - - File.expects(:join).with("/rails/root", @path). - returns("/former/relative/path") - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + Pathname.stub(:new, @path) do + @path.stub(:absolute?, false) do + assert_called_with(File, :join, ["/rails/root", @path], + returns: "/former/relative/path" + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end + end end def test_removes_file_with_relative_path - File.stubs(:exist?).returns(true) - @path.stubs(:absolute?).returns(false) - - FileUtils.expects(:rm).with("/former/relative/path") - - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + File.stub(:join, "/former/relative/path") do + @path.stub(:absolute?, false) do + assert_called_with(FileUtils, :rm, ["/former/relative/path"]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end + end end def test_when_db_dropped_successfully_outputs_info_to_stdout - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + FileUtils.stub(:rm, nil) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" - assert_equal "Dropped database '#{@database}'\n", $stdout.string + assert_equal "Dropped database '#{@database}'\n", $stdout.string + end end end class SqliteDBCharsetTest < ActiveRecord::TestCase def setup @database = "db_create.sqlite3" - @connection = stub :connection + @connection = Class.new { def encoding; end }.new @configuration = { "adapter" => "sqlite3", "database" => @database } - - File.stubs(:exist?).returns(false) - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_db_retrieves_charset - @connection.expects(:encoding) - ActiveRecord::Tasks::DatabaseTasks.charset @configuration, "/rails/root" + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :encoding) do + ActiveRecord::Tasks::DatabaseTasks.charset @configuration, "/rails/root" + end + end end end class SqliteDBCollationTest < ActiveRecord::TestCase def setup @database = "db_create.sqlite3" - @connection = stub :connection @configuration = { "adapter" => "sqlite3", "database" => @database } - - File.stubs(:exist?).returns(false) - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def test_db_retrieves_collation @@ -180,6 +174,9 @@ if current_adapter?(:SQLite3Adapter) "adapter" => "sqlite3", "database" => @database } + + `sqlite3 #{@database} 'CREATE TABLE bar(id INTEGER)'` + `sqlite3 #{@database} 'CREATE TABLE foo(id INTEGER)'` end def test_structure_dump @@ -189,10 +186,57 @@ if current_adapter?(:SQLite3Adapter) ActiveRecord::Tasks::DatabaseTasks.structure_dump @configuration, filename, "/rails/root" assert File.exist?(dbfile) assert File.exist?(filename) + assert_match(/CREATE TABLE foo/, File.read(filename)) + assert_match(/CREATE TABLE bar/, File.read(filename)) + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + + def test_structure_dump_with_ignore_tables + dbfile = @database + filename = "awesome-file.sql" + assert_called(ActiveRecord::SchemaDumper, :ignore_tables, returns: ["foo"]) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") + end + assert File.exist?(dbfile) + assert File.exist?(filename) + assert_match(/bar/, File.read(filename)) + assert_no_match(/foo/, File.read(filename)) ensure FileUtils.rm_f(filename) FileUtils.rm_f(dbfile) end + + def test_structure_dump_execution_fails + dbfile = @database + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["sqlite3", "--noop", "db_create.sqlite3", ".schema", out: "awesome-file.sql"], + returns: nil + ) do + e = assert_raise(RuntimeError) do + with_structure_dump_flags(["--noop"]) do + quietly { ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") } + end + end + assert_match("failed to execute:", e.message) + end + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + + private + def with_structure_dump_flags(flags) + old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags + yield + ensure + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old + end end class SqliteStructureLoadTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb index 31b11c19f7..40947767f3 100644 --- a/activerecord/test/cases/test_case.rb +++ b/activerecord/test/cases/test_case.rb @@ -1,4 +1,6 @@ -require "active_support/test_case" +# frozen_string_literal: true + +require "active_support" require "active_support/testing/autorun" require "active_support/testing/method_call_assertions" require "active_support/testing/stream" @@ -29,6 +31,7 @@ module ActiveRecord end def capture_sql + ActiveRecord::Base.connection.materialize_transactions SQLCounter.clear_log yield SQLCounter.log_all.dup @@ -46,6 +49,7 @@ module ActiveRecord def assert_queries(num = 1, options = {}) ignore_none = options.fetch(:ignore_none) { num == :any } + ActiveRecord::Base.connection.materialize_transactions SQLCounter.clear_log x = yield the_log = ignore_none ? SQLCounter.log_all : SQLCounter.log @@ -75,6 +79,10 @@ module ActiveRecord model.reset_column_information model.column_names.include?(column_name.to_s) end + + def frozen_error_class + Object.const_defined?(:FrozenError) ? FrozenError : RuntimeError + end end class PostgreSQLTestCase < TestCase @@ -108,9 +116,9 @@ module ActiveRecord # FIXME: this needs to be refactored so specific database can add their own # ignored SQL, or better yet, use a different notification for the queries # instead examining the SQL content. - oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im] + oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im, /^\s*select .* from all_sequences/im] mysql_ignored = [/^SHOW FULL TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /, /^\s*SELECT (?:column_name|table_name)\b.*\bFROM information_schema\.(?:key_column_usage|tables)\b/im] - postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i] + postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i, /^\s*SELECT\b.*::regtype::oid\b/im] sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im, /^\s*SELECT sql\b.*\bFROM sqlite_master/im] [oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql| diff --git a/activerecord/test/cases/test_fixtures_test.rb b/activerecord/test/cases/test_fixtures_test.rb index 58d3bea3a2..4411410eda 100644 --- a/activerecord/test/cases/test_fixtures_test.rb +++ b/activerecord/test/cases/test_fixtures_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class TestFixturesTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/time_precision_test.rb b/activerecord/test/cases/time_precision_test.rb index 09c585167e..086500de38 100644 --- a/activerecord/test/cases/time_precision_test.rb +++ b/activerecord/test/cases/time_precision_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/schema_dumping_helper" @@ -25,6 +27,24 @@ if subsecond_precision_supported? assert_equal 6, Foo.columns_hash["finish"].precision end + def test_time_precision_is_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :start, :time, precision: 0 + @connection.add_column :foos, :finish, :time, precision: 6 + + time = ::Time.now.change(nsec: 123456789) + foo = Foo.new(start: time, finish: time) + + assert_equal 0, foo.start.nsec + assert_equal 123456000, foo.finish.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.start.nsec + assert_equal 123456000, foo.finish.nsec + end + def test_passing_precision_to_time_does_not_set_limit @connection.create_table(:foos, force: true) do |t| t.time :start, precision: 3 diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb index 39b40e3411..75ecd6fc40 100644 --- a/activerecord/test/cases/timestamp_test.rb +++ b/activerecord/test/cases/timestamp_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "support/ddl_helper" require "models/developer" @@ -88,12 +90,22 @@ class TimestampTest < ActiveRecord::TestCase @developer.touch(:created_at) end - assert !@developer.created_at_changed? , "created_at should not be changed" - assert !@developer.changed?, "record should not be changed" + assert_not @developer.created_at_changed?, "created_at should not be changed" + assert_not @developer.changed?, "record should not be changed" assert_not_equal previously_created_at, @developer.created_at assert_not_equal @previously_updated_at, @developer.updated_at end + def test_touching_update_at_attribute_as_symbol_updates_timestamp + travel(1.second) do + @developer.touch(:updated_at) + end + + assert_not @developer.updated_at_changed? + assert_not @developer.changed? + assert_not_equal @previously_updated_at, @developer.updated_at + end + def test_touching_an_attribute_updates_it task = Task.first previous_value = task.ending @@ -137,13 +149,13 @@ class TimestampTest < ActiveRecord::TestCase def test_touching_a_no_touching_object Developer.no_touching do - assert @developer.no_touching? - assert !@owner.no_touching? + assert_predicate @developer, :no_touching? + assert_not_predicate @owner, :no_touching? @developer.touch end - assert !@developer.no_touching? - assert !@owner.no_touching? + assert_not_predicate @developer, :no_touching? + assert_not_predicate @owner, :no_touching? assert_equal @previously_updated_at, @developer.updated_at end @@ -160,26 +172,26 @@ class TimestampTest < ActiveRecord::TestCase def test_global_no_touching ActiveRecord::Base.no_touching do - assert @developer.no_touching? - assert @owner.no_touching? + assert_predicate @developer, :no_touching? + assert_predicate @owner, :no_touching? @developer.touch end - assert !@developer.no_touching? - assert !@owner.no_touching? + assert_not_predicate @developer, :no_touching? + assert_not_predicate @owner, :no_touching? assert_equal @previously_updated_at, @developer.updated_at end def test_no_touching_threadsafe Thread.new do Developer.no_touching do - assert @developer.no_touching? + assert_predicate @developer, :no_touching? sleep(1) end end - assert !@developer.no_touching? + assert_not_predicate @developer, :no_touching? end def test_no_touching_with_callbacks @@ -235,7 +247,7 @@ class TimestampTest < ActiveRecord::TestCase pet = Pet.new(owner: klass.new) pet.save! - assert pet.owner.new_record? + assert_predicate pet.owner, :new_record? end def test_saving_a_record_with_a_belongs_to_that_specifies_touching_a_specific_attribute_the_parent_should_update_that_attribute @@ -444,17 +456,6 @@ class TimestampTest < ActiveRecord::TestCase toy = Toy.first assert_equal ["created_at", "updated_at"], toy.send(:all_timestamp_attributes_in_model) end - - def test_index_is_created_for_both_timestamps - ActiveRecord::Base.connection.create_table(:foos, force: true) do |t| - t.timestamps null: true, index: true - end - - indexes = ActiveRecord::Base.connection.indexes("foos") - assert_equal ["created_at", "updated_at"], indexes.flat_map(&:columns).sort - ensure - ActiveRecord::Base.connection.drop_table(:foos) - end end class TimestampsWithoutTransactionTest < ActiveRecord::TestCase @@ -473,4 +474,15 @@ class TimestampsWithoutTransactionTest < ActiveRecord::TestCase assert_nil post.updated_at end end + + def test_index_is_created_for_both_timestamps + ActiveRecord::Base.connection.create_table(:foos, force: true) do |t| + t.timestamps null: true, index: true + end + + indexes = ActiveRecord::Base.connection.indexes("foos") + assert_equal ["created_at", "updated_at"], indexes.flat_map(&:columns).sort + ensure + ActiveRecord::Base.connection.drop_table(:foos) + end end diff --git a/activerecord/test/cases/touch_later_test.rb b/activerecord/test/cases/touch_later_test.rb index d1e8c649d9..925a4609a2 100644 --- a/activerecord/test/cases/touch_later_test.rb +++ b/activerecord/test/cases/touch_later_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/invoice" require "models/line_item" @@ -11,7 +13,7 @@ class TouchLaterTest < ActiveRecord::TestCase def test_touch_laster_raise_if_non_persisted invoice = Invoice.new Invoice.transaction do - assert_not invoice.persisted? + assert_not_predicate invoice, :persisted? assert_raises(ActiveRecord::ActiveRecordError) do invoice.touch_later end @@ -21,7 +23,7 @@ class TouchLaterTest < ActiveRecord::TestCase def test_touch_later_dont_set_dirty_attributes invoice = Invoice.create! invoice.touch_later - assert_not invoice.changed? + assert_not_predicate invoice, :changed? end def test_touch_later_respects_no_touching_policy diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb index 391bbe8877..c0be45eee7 100644 --- a/activerecord/test/cases/transaction_callbacks_test.rb +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/owner" require "models/pet" @@ -137,6 +139,23 @@ class TransactionCallbacksTest < ActiveRecord::TestCase assert_equal [], reply.history end + def test_only_call_after_commit_on_destroy_after_transaction_commits_for_destroyed_new_record + new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) + add_transaction_execution_blocks new_record + + new_record.destroy + assert_equal [:commit_on_destroy], new_record.history + end + + def test_save_in_after_create_commit_wont_invoke_extra_after_create_commit + new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) + add_transaction_execution_blocks new_record + new_record.after_commit_block(:create) { |r| r.save! } + + new_record.save! + assert_equal [:commit_on_create, :commit_on_update], new_record.history + end + def test_only_call_after_commit_on_create_and_doesnt_leaky r = ReplyWithCallbacks.new(content: "foo") r.save_on_after_create = true @@ -156,13 +175,13 @@ class TransactionCallbacksTest < ActiveRecord::TestCase def test_only_call_after_commit_on_top_level_transactions @first.after_commit_block { |r| r.history << :after_commit } - assert @first.history.empty? + assert_empty @first.history @first.transaction do @first.transaction(requires_new: true) do @first.touch end - assert @first.history.empty? + assert_empty @first.history end assert_equal [:after_commit], @first.history end @@ -365,6 +384,26 @@ class TransactionCallbacksTest < ActiveRecord::TestCase assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message) end + def test_after_commit_chain_not_called_on_errors + record_1 = TopicWithCallbacks.create! + record_2 = TopicWithCallbacks.create! + record_3 = TopicWithCallbacks.create! + callbacks = [] + record_1.after_commit_block { raise } + record_2.after_commit_block { callbacks << record_2.id } + record_3.after_commit_block { callbacks << record_3.id } + begin + TopicWithCallbacks.transaction do + record_1.save! + record_2.save! + record_3.save! + end + rescue + # From record_1.after_commit + end + assert_equal [], callbacks + end + def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_call_callbacks_on_the_parent_object pet = Pet.first owner = pet.owner @@ -392,6 +431,28 @@ class TransactionCallbacksTest < ActiveRecord::TestCase end end +class TransactionAfterCommitCallbacksWithOptimisticLockingTest < ActiveRecord::TestCase + class PersonWithCallbacks < ActiveRecord::Base + self.table_name = :people + + after_create_commit { |record| record.history << :commit_on_create } + after_update_commit { |record| record.history << :commit_on_update } + after_destroy_commit { |record| record.history << :commit_on_destroy } + + def history + @history ||= [] + end + end + + def test_after_commit_callbacks_with_optimistic_locking + person = PersonWithCallbacks.create!(first_name: "first name") + person.update!(first_name: "another name") + person.destroy + + assert_equal [:commit_on_create, :commit_on_update, :commit_on_destroy], person.history + end +end + class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase self.use_transactional_tests = false @@ -516,7 +577,7 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase @topic.content = "foo" @topic.save! end - assert @topic.history.empty? + assert_empty @topic.history end def test_commit_run_transactions_callbacks_with_explicit_enrollment @@ -536,7 +597,7 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase @topic.save! raise ActiveRecord::Rollback end - assert @topic.history.empty? + assert_empty @topic.history end def test_rollback_run_transactions_callbacks_with_explicit_enrollment @@ -551,3 +612,43 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase assert_equal [:rollback], @topic.history end end + +class CallbacksOnActionAndConditionTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class TopicWithCallbacksOnActionAndCondition < ActiveRecord::Base + self.table_name = :topics + + after_commit(on: [:create, :update], if: :run_callback?) { |record| record.history << :create_or_update } + + def clear_history + @history = [] + end + + def history + @history ||= [] + end + + def run_callback? + self.history << :run_callback? + true + end + + attr_accessor :save_before_commit_history, :update_title + end + + def test_callback_on_action_with_condition + topic = TopicWithCallbacksOnActionAndCondition.new + topic.save + assert_equal [:run_callback?, :create_or_update], topic.history + + topic.clear_history + topic.approved = true + topic.save + assert_equal [:run_callback?, :create_or_update], topic.history + + topic.clear_history + topic.destroy + assert_equal [], topic.history + end +end diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index 58abfadaf4..9663955f1f 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" unless ActiveRecord::Base.connection.supports_transaction_isolation? @@ -9,13 +11,11 @@ unless ActiveRecord::Base.connection.supports_transaction_isolation? test "setting the isolation level raises an error" do assert_raises(ActiveRecord::TransactionIsolationError) do - Tag.transaction(isolation: :serializable) {} + Tag.transaction(isolation: :serializable) { Tag.connection.materialize_transactions } end end end -end - -if ActiveRecord::Base.connection.supports_transaction_isolation? +else class TransactionIsolationTest < ActiveRecord::TestCase self.use_transactional_tests = false diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb index 111495c481..b13cf88c00 100644 --- a/activerecord/test/cases/transactions_test.rb +++ b/activerecord/test/cases/transactions_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" @@ -10,7 +12,7 @@ require "models/movie" class TransactionTest < ActiveRecord::TestCase self.use_transactional_tests = false - fixtures :topics, :developers, :authors, :posts + fixtures :topics, :developers, :authors, :author_addresses, :posts def setup @first, @second = Topic.find(1, 2).sort_by(&:id) @@ -18,22 +20,22 @@ class TransactionTest < ActiveRecord::TestCase def test_persisted_in_a_model_with_custom_primary_key_after_failed_save movie = Movie.create - assert !movie.persisted? + assert_not_predicate movie, :persisted? end def test_raise_after_destroy - assert_not @first.frozen? + assert_not_predicate @first, :frozen? assert_raises(RuntimeError) { Topic.transaction do @first.destroy - assert @first.frozen? + assert_predicate @first, :frozen? raise end } assert @first.reload - assert_not @first.frozen? + assert_not_predicate @first, :frozen? end def test_successful @@ -45,7 +47,7 @@ class TransactionTest < ActiveRecord::TestCase end assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_not Topic.find(2).approved?, "Second should have been unapproved" end def transaction_with_return @@ -78,7 +80,7 @@ class TransactionTest < ActiveRecord::TestCase assert committed assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_not Topic.find(2).approved?, "Second should have been unapproved" ensure Topic.connection.class_eval do remove_method :commit_db_transaction @@ -119,7 +121,7 @@ class TransactionTest < ActiveRecord::TestCase end assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_not Topic.find(2).approved?, "Second should have been unapproved" end def test_failing_on_exception @@ -136,9 +138,9 @@ class TransactionTest < ActiveRecord::TestCase end assert @first.approved?, "First should still be changed in the objects" - assert !@second.approved?, "Second should still be changed in the objects" + assert_not @second.approved?, "Second should still be changed in the objects" - assert !Topic.find(1).approved?, "First shouldn't have been approved" + assert_not Topic.find(1).approved?, "First shouldn't have been approved" assert Topic.find(2).approved?, "Second should still be approved" end @@ -150,20 +152,20 @@ class TransactionTest < ActiveRecord::TestCase @first.approved = true e = assert_raises(RuntimeError) { @first.save } assert_equal "Make the transaction rollback", e.message - assert !Topic.find(1).approved? + assert_not_predicate Topic.find(1), :approved? end def test_rolling_back_in_a_callback_rollbacks_before_save def @first.before_save_for_transaction raise ActiveRecord::Rollback end - assert !@first.approved + assert_not @first.approved Topic.transaction do @first.approved = true @first.save! end - assert !Topic.find(@first.id).approved?, "Should not commit the approved flag" + assert_not Topic.find(@first.id).approved?, "Should not commit the approved flag" end def test_raising_exception_in_nested_transaction_restore_state_in_save @@ -184,7 +186,7 @@ class TransactionTest < ActiveRecord::TestCase author = Author.create! name: "foo" author.name = nil assert_not author.save - assert_not author.new_record? + assert_not_predicate author, :new_record? end def test_update_should_rollback_on_failure @@ -192,7 +194,7 @@ class TransactionTest < ActiveRecord::TestCase posts_count = author.posts.size assert posts_count > 0 status = author.update(name: nil, post_ids: []) - assert !status + assert_not status assert_equal posts_count, author.posts.reload.size end @@ -210,7 +212,7 @@ class TransactionTest < ActiveRecord::TestCase add_cancelling_before_destroy_with_db_side_effect_to_topic @first nbooks_before_destroy = Book.count status = @first.destroy - assert !status + assert_not status @first.reload assert_equal nbooks_before_destroy, Book.count end @@ -222,7 +224,7 @@ class TransactionTest < ActiveRecord::TestCase original_author_name = @first.author_name @first.author_name += "_this_should_not_end_up_in_the_db" status = @first.save - assert !status + assert_not status assert_equal original_author_name, @first.reload.author_name assert_equal nbooks_before_save, Book.count end @@ -286,7 +288,19 @@ class TransactionTest < ActiveRecord::TestCase } new_topic = topic.create(title: "A new topic") - assert !new_topic.persisted?, "The topic should not be persisted" + assert_not new_topic.persisted?, "The topic should not be persisted" + assert_nil new_topic.id, "The topic should not have an ID" + end + + def test_callback_rollback_in_create_with_rollback_exception + topic = Class.new(Topic) { + def after_create_for_transaction + raise ActiveRecord::Rollback + end + } + + new_topic = topic.create(title: "A new topic") + assert_not new_topic.persisted?, "The topic should not be persisted" assert_nil new_topic.id, "The topic should not have an ID" end @@ -301,7 +315,77 @@ class TransactionTest < ActiveRecord::TestCase end assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_not Topic.find(2).approved?, "Second should have been unapproved" + end + + def test_nested_transaction_with_new_transaction_applies_parent_state_on_rollback + topic_one = Topic.new(title: "A new topic") + topic_two = Topic.new(title: "Another new topic") + + Topic.transaction do + topic_one.save + + Topic.transaction(requires_new: true) do + topic_two.save + + assert_predicate topic_one, :persisted? + assert_predicate topic_two, :persisted? + end + + raise ActiveRecord::Rollback + end + + assert_not_predicate topic_one, :persisted? + assert_not_predicate topic_two, :persisted? + end + + def test_nested_transaction_without_new_transaction_applies_parent_state_on_rollback + topic_one = Topic.new(title: "A new topic") + topic_two = Topic.new(title: "Another new topic") + + Topic.transaction do + topic_one.save + + Topic.transaction do + topic_two.save + + assert_predicate topic_one, :persisted? + assert_predicate topic_two, :persisted? + end + + raise ActiveRecord::Rollback + end + + assert_not_predicate topic_one, :persisted? + assert_not_predicate topic_two, :persisted? + end + + def test_double_nested_transaction_applies_parent_state_on_rollback + topic_one = Topic.new(title: "A new topic") + topic_two = Topic.new(title: "Another new topic") + topic_three = Topic.new(title: "Another new topic of course") + + Topic.transaction do + topic_one.save + + Topic.transaction do + topic_two.save + + Topic.transaction do + topic_three.save + end + end + + assert_predicate topic_one, :persisted? + assert_predicate topic_two, :persisted? + assert_predicate topic_three, :persisted? + + raise ActiveRecord::Rollback + end + + assert_not_predicate topic_one, :persisted? + assert_not_predicate topic_two, :persisted? + assert_not_predicate topic_three, :persisted? end def test_manually_rolling_back_a_transaction @@ -315,9 +399,9 @@ class TransactionTest < ActiveRecord::TestCase end assert @first.approved?, "First should still be changed in the objects" - assert !@second.approved?, "Second should still be changed in the objects" + assert_not @second.approved?, "Second should still be changed in the objects" - assert !Topic.find(1).approved?, "First shouldn't have been approved" + assert_not Topic.find(1).approved?, "First shouldn't have been approved" assert Topic.find(2).approved?, "Second should still be approved" end @@ -345,8 +429,8 @@ class TransactionTest < ActiveRecord::TestCase end end - assert @first.reload.approved? - assert !@second.reload.approved? + assert_predicate @first.reload, :approved? + assert_not_predicate @second.reload, :approved? end if Topic.connection.supports_savepoints? def test_force_savepoint_on_instance @@ -366,8 +450,8 @@ class TransactionTest < ActiveRecord::TestCase end end - assert @first.reload.approved? - assert !@second.reload.approved? + assert_predicate @first.reload, :approved? + assert_not_predicate @second.reload, :approved? end if Topic.connection.supports_savepoints? def test_no_savepoint_in_nested_transaction_without_force @@ -387,8 +471,8 @@ class TransactionTest < ActiveRecord::TestCase end end - assert !@first.reload.approved? - assert !@second.reload.approved? + assert_not_predicate @first.reload, :approved? + assert_not_predicate @second.reload, :approved? end if Topic.connection.supports_savepoints? def test_many_savepoints @@ -444,12 +528,12 @@ class TransactionTest < ActiveRecord::TestCase @first.approved = false @first.save! Topic.connection.rollback_to_savepoint("first") - assert @first.reload.approved? + assert_predicate @first.reload, :approved? @first.approved = false @first.save! Topic.connection.release_savepoint("first") - assert_not @first.reload.approved? + assert_not_predicate @first.reload, :approved? end end if Topic.connection.supports_savepoints? @@ -489,10 +573,9 @@ class TransactionTest < ActiveRecord::TestCase assert_called(Topic.connection, :begin_db_transaction) do Topic.connection.stub(:commit_db_transaction, -> { raise("OH NOES") }) do assert_called(Topic.connection, :rollback_db_transaction) do - e = assert_raise RuntimeError do Topic.transaction do - # do nothing + Topic.connection.materialize_transactions end end assert_equal "OH NOES", e.message @@ -504,12 +587,12 @@ class TransactionTest < ActiveRecord::TestCase def test_rollback_when_saving_a_frozen_record topic = Topic.new(title: "test") topic.freeze - e = assert_raise(RuntimeError) { topic.save } + e = assert_raise(frozen_error_class) { topic.save } # Not good enough, but we can't do much # about it since there is no specific error # for frozen objects. assert_match(/frozen/i, e.message) - assert !topic.persisted?, "not persisted" + assert_not topic.persisted?, "not persisted" assert_nil topic.id assert topic.frozen?, "not frozen" end @@ -536,9 +619,9 @@ class TransactionTest < ActiveRecord::TestCase thread.join assert @first.approved?, "First should still be changed in the objects" - assert !@second.approved?, "Second should still be changed in the objects" + assert_not @second.approved?, "Second should still be changed in the objects" - assert !Topic.find(1).approved?, "First shouldn't have been approved" + assert_not Topic.find(1).approved?, "First shouldn't have been approved" assert Topic.find(2).approved?, "Second should still be approved" end @@ -569,15 +652,15 @@ class TransactionTest < ActiveRecord::TestCase raise ActiveRecord::Rollback end - assert !topic_1.persisted?, "not persisted" + assert_not topic_1.persisted?, "not persisted" assert_nil topic_1.id - assert !topic_2.persisted?, "not persisted" + assert_not topic_2.persisted?, "not persisted" assert_nil topic_2.id - assert !topic_3.persisted?, "not persisted" + assert_not topic_3.persisted?, "not persisted" assert_nil topic_3.id assert @first.persisted?, "persisted" assert_not_nil @first.id - assert !@second.destroyed?, "not destroyed" + assert_not @second.destroyed?, "not destroyed" end def test_restore_frozen_state_after_double_destroy @@ -591,8 +674,130 @@ class TransactionTest < ActiveRecord::TestCase raise ActiveRecord::Rollback end - assert_not reply.frozen? - assert_not topic.frozen? + assert_not_predicate reply, :frozen? + assert_not_predicate topic, :frozen? + end + + def test_restore_new_record_after_double_save + topic = Topic.new + + Topic.transaction do + topic.save! + topic.save! + raise ActiveRecord::Rollback + end + + assert_nil topic.id + assert_predicate topic, :new_record? + end + + def test_dont_restore_new_record_in_subsequent_transaction + topic = Topic.new + + Topic.transaction do + topic.save! + topic.save! + end + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + assert_predicate topic, :persisted? + assert_not_predicate topic, :new_record? + end + + def test_restore_id_after_rollback + topic = Topic.new + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + assert_nil topic.id + end + + def test_restore_custom_primary_key_after_rollback + movie = Movie.new(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + assert_nil movie.movieid + end + + def test_assign_id_after_rollback + topic = Topic.create! + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + topic.id = nil + assert_nil topic.id + end + + def test_assign_custom_primary_key_after_rollback + movie = Movie.create!(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + movie.movieid = nil + assert_nil movie.movieid + end + + def test_read_attribute_after_rollback + topic = Topic.new + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + assert_nil topic.read_attribute(:id) + end + + def test_read_attribute_with_custom_primary_key_after_rollback + movie = Movie.new(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + assert_nil movie.read_attribute(:movieid) + end + + def test_write_attribute_after_rollback + topic = Topic.create! + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + topic.write_attribute(:id, nil) + assert_nil topic.id + end + + def test_write_attribute_with_custom_primary_key_after_rollback + movie = Movie.create!(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + movie.write_attribute(:movieid, nil) + assert_nil movie.movieid end def test_rollback_of_frozen_records @@ -655,28 +860,66 @@ class TransactionTest < ActiveRecord::TestCase connection = Topic.connection transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction - assert transaction.open? - assert !transaction.state.rolledback? - assert !transaction.state.committed? + assert_predicate transaction, :open? + assert_not_predicate transaction.state, :rolledback? + assert_not_predicate transaction.state, :committed? transaction.rollback - assert transaction.state.rolledback? - assert !transaction.state.committed? + assert_predicate transaction.state, :rolledback? + assert_not_predicate transaction.state, :committed? end def test_transactions_state_from_commit connection = Topic.connection transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction - assert transaction.open? - assert !transaction.state.rolledback? - assert !transaction.state.committed? + assert_predicate transaction, :open? + assert_not_predicate transaction.state, :rolledback? + assert_not_predicate transaction.state, :committed? + + transaction.commit + + assert_not_predicate transaction.state, :rolledback? + assert_predicate transaction.state, :committed? + end + + def test_set_state_method_is_deprecated + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.commit + + assert_deprecated do + transaction.state.set_state(:rolledback) + end + end + + def test_mark_transaction_state_as_committed + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.rollback + + assert_equal :committed, transaction.state.commit! + end + + def test_mark_transaction_state_as_rolledback + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction transaction.commit - assert !transaction.state.rolledback? - assert transaction.state.committed? + assert_equal :rolledback, transaction.state.rollback! + end + + def test_mark_transaction_state_as_nil + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.commit + + assert_nil transaction.state.nullify! end def test_transaction_rollback_with_primarykeyless_tables @@ -700,6 +943,76 @@ class TransactionTest < ActiveRecord::TestCase connection.drop_table "transaction_without_primary_keys", if_exists: true end + def test_empty_transaction_is_not_materialized + assert_no_queries do + Topic.transaction {} + end + end + + def test_unprepared_statement_materializes_transaction + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { Topic.where("1=1").first } + end + end + + if ActiveRecord::Base.connection.prepared_statements + def test_prepared_statement_materializes_transaction + Topic.first + + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { Topic.first } + end + end + end + + def test_savepoint_does_not_materialize_transaction + assert_no_queries do + Topic.transaction do + Topic.transaction(requires_new: true) {} + end + end + end + + def test_raising_does_not_materialize_transaction + assert_raise(RuntimeError) do + assert_no_queries do + Topic.transaction { raise } + end + end + end + + def test_accessing_raw_connection_materializes_transaction + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { Topic.connection.raw_connection } + end + end + + def test_accessing_raw_connection_disables_lazy_transactions + Topic.connection.raw_connection + + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction {} + end + end + + def test_checking_in_connection_reenables_lazy_transactions + connection = Topic.connection_pool.checkout + connection.raw_connection + Topic.connection_pool.checkin connection + + assert_no_queries do + connection.transaction {} + end + end + + def test_transactions_can_be_manually_materialized + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction do + Topic.connection.materialize_transactions + end + end + end + private %w(validation save destroy).each do |filter| @@ -727,7 +1040,7 @@ class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase raise end rescue - assert !@first.reload.approved? + assert_not_predicate @first.reload, :approved? end end @@ -748,31 +1061,29 @@ class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase end end - assert !@first.reload.approved? + assert_not_predicate @first.reload, :approved? end end if Topic.connection.supports_savepoints? -if current_adapter?(:PostgreSQLAdapter) +if ActiveRecord::Base.connection.supports_transaction_isolation? class ConcurrentTransactionTest < TransactionTest # This will cause transactions to overlap and fail unless they are performed on # separate database connections. - unless in_memory_db? - def test_transaction_per_thread - threads = 3.times.map do - Thread.new do - Topic.transaction do - topic = Topic.find(1) - topic.approved = !topic.approved? - assert topic.save! - topic.approved = !topic.approved? - assert topic.save! - end - Topic.connection.close + def test_transaction_per_thread + threads = 3.times.map do + Thread.new do + Topic.transaction do + topic = Topic.find(1) + topic.approved = !topic.approved? + assert topic.save! + topic.approved = !topic.approved? + assert topic.save! end + Topic.connection.close end - - threads.each(&:join) end + + threads.each(&:join) end # Test for dirty reads among simultaneous transactions. diff --git a/activerecord/test/cases/type/adapter_specific_registry_test.rb b/activerecord/test/cases/type/adapter_specific_registry_test.rb index 8b836b4793..b58bdd5549 100644 --- a/activerecord/test/cases/type/adapter_specific_registry_test.rb +++ b/activerecord/test/cases/type/adapter_specific_registry_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/type/date_time_test.rb b/activerecord/test/cases/type/date_time_test.rb index 6848619ece..c9558e25b5 100644 --- a/activerecord/test/cases/type/date_time_test.rb +++ b/activerecord/test/cases/type/date_time_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/task" diff --git a/activerecord/test/cases/type/integer_test.rb b/activerecord/test/cases/type/integer_test.rb index 368b6d7199..15d1a675a1 100644 --- a/activerecord/test/cases/type/integer_test.rb +++ b/activerecord/test/cases/type/integer_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/company" diff --git a/activerecord/test/cases/type/string_test.rb b/activerecord/test/cases/type/string_test.rb index a95da864fa..9e7810a6a5 100644 --- a/activerecord/test/cases/type/string_test.rb +++ b/activerecord/test/cases/type/string_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord @@ -7,16 +9,16 @@ module ActiveRecord klass.table_name = "authors" author = klass.create!(name: "Sean") - assert_not author.changed? + assert_not_predicate author, :changed? author.name << " Griffin" - assert author.name_changed? + assert_predicate author, :name_changed? author.save! author.reload assert_equal "Sean Griffin", author.name - assert_not author.changed? + assert_not_predicate author, :changed? end end end diff --git a/activerecord/test/cases/type/type_map_test.rb b/activerecord/test/cases/type/type_map_test.rb index 2959d36466..f3699c11a2 100644 --- a/activerecord/test/cases/type/type_map_test.rb +++ b/activerecord/test/cases/type/type_map_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/type/unsigned_integer_test.rb b/activerecord/test/cases/type/unsigned_integer_test.rb index 1cd4dbc2c5..dd05cf3fff 100644 --- a/activerecord/test/cases/type/unsigned_integer_test.rb +++ b/activerecord/test/cases/type/unsigned_integer_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/type_test.rb b/activerecord/test/cases/type_test.rb index d45a9b3141..93ae563c8b 100644 --- a/activerecord/test/cases/type_test.rb +++ b/activerecord/test/cases/type_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class TypeTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/types_test.rb b/activerecord/test/cases/types_test.rb index 11476ea0ef..3f7fb0a604 100644 --- a/activerecord/test/cases/types_test.rb +++ b/activerecord/test/cases/types_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" module ActiveRecord diff --git a/activerecord/test/cases/unconnected_test.rb b/activerecord/test/cases/unconnected_test.rb index b210584644..9eefc32745 100644 --- a/activerecord/test/cases/unconnected_test.rb +++ b/activerecord/test/cases/unconnected_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" class TestRecord < ActiveRecord::Base @@ -28,6 +30,6 @@ class TestUnconnectedAdapter < ActiveRecord::TestCase end def test_underlying_adapter_no_longer_active - assert !@underlying.active?, "Removed adapter should no longer be active" + assert_not @underlying.active?, "Removed adapter should no longer be active" end end diff --git a/activerecord/test/cases/unsafe_raw_sql_test.rb b/activerecord/test/cases/unsafe_raw_sql_test.rb new file mode 100644 index 0000000000..d5d8f2a09a --- /dev/null +++ b/activerecord/test/cases/unsafe_raw_sql_test.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +class UnsafeRawSqlTest < ActiveRecord::TestCase + fixtures :posts, :comments + + test "order: allows string column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows symbol column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:title).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:title).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows downcase symbol direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :asc).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :asc).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows upcase symbol direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("ASC")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :ASC).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :ASC).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows string direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: "asc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: "asc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows multiple columns" do + ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, :title).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, :title).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows mixed" do + ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, title: :asc).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, title: :asc).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows table and column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows column name and direction in string" do + ids_expected = Post.order(Arel.sql("title desc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title desc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title desc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows table name, column name and direction in string" do + ids_expected = Post.order(Arel.sql("title desc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title desc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title desc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows NULLS FIRST and NULLS LAST too" do + raise "precondition failed" if Post.count < 2 + + # Ensure there are NULL and non-NULL post types. + Post.first.update_column(:type, nil) + Post.last.update_column(:type, "Programming") + + ["asc", "desc", ""].each do |direction| + %w(first last).each do |position| + ids_expected = Post.order(Arel.sql("type #{direction} nulls #{position}")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("type #{direction} nulls #{position}").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("type #{direction} nulls #{position}").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + end + end if current_adapter?(:PostgreSQLAdapter) + + test "order: disallows invalid column name" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order("len(title) asc").pluck(:id) + end + end + end + + test "order: disallows invalid direction" do + with_unsafe_raw_sql_disabled do + assert_raises(ArgumentError) do + Post.order(title: :foo).pluck(:id) + end + end + end + + test "order: disallows invalid column with direction" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order("len(title)" => :asc).pluck(:id) + end + end + end + + test "order: always allows Arel" do + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(Arel.sql("length(title)")).pluck(:title) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(Arel.sql("length(title)")).pluck(:title) } + + assert_equal ids_depr, ids_disabled + end + + test "order: allows Arel.sql with binds" do + ids_expected = Post.order(Arel.sql("REPLACE(title, 'misc', 'zzzz'), id")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: disallows invalid bind statement" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order(["REPLACE(title, ?, ?), id", "misc", "zzzz"]).pluck(:id) + end + end + end + + test "order: disallows invalid Array arguments" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order(["author_id", "length(title)"]).pluck(:id) + end + end + end + + test "order: allows valid Array arguments" do + ids_expected = Post.order(Arel.sql("author_id, length(title)")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: logs deprecation warning for unrecognized column" do + with_unsafe_raw_sql_deprecated do + assert_deprecated(/Dangerous query method/) do + Post.order("length(title)") + end + end + end + + test "pluck: allows string column name" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("title") } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("title") } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: allows symbol column name" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title) } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title) } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: allows multiple column names" do + values_expected = Post.pluck(Arel.sql("title"), Arel.sql("id")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title, :id) } + values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title, :id) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows column names with includes" do + values_expected = Post.includes(:comments).pluck(Arel.sql("title"), Arel.sql("id")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, :id) } + values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, :id) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows auto-generated attributes" do + values_expected = Post.pluck(Arel.sql("tags_count")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:tags_count) } + values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:tags_count) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows table and column names" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("posts.title") } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("posts.title") } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: disallows invalid column name" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.pluck("length(title)") + end + end + end + + test "pluck: disallows invalid column name amongst valid names" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.pluck(:title, "length(title)") + end + end + end + + test "pluck: disallows invalid column names with includes" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.includes(:comments).pluck(:title, "length(title)") + end + end + end + + test "pluck: always allows Arel" do + values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) } + values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) } + + assert_equal values_depr, values_disabled + end + + test "pluck: logs deprecation warning" do + with_unsafe_raw_sql_deprecated do + assert_deprecated(/Dangerous query method/) do + Post.includes(:comments).pluck(:title, "length(title)") + end + end + end + + def with_unsafe_raw_sql_disabled(&blk) + with_config(:disabled, &blk) + end + + def with_unsafe_raw_sql_deprecated(&blk) + with_config(:deprecated, &blk) + end + + def with_config(new_value, &blk) + old_value = ActiveRecord::Base.allow_unsafe_raw_sql + ActiveRecord::Base.allow_unsafe_raw_sql = new_value + blk.call + ensure + ActiveRecord::Base.allow_unsafe_raw_sql = old_value + end +end diff --git a/activerecord/test/cases/validations/absence_validation_test.rb b/activerecord/test/cases/validations/absence_validation_test.rb index 870619e4e7..8235a54d8a 100644 --- a/activerecord/test/cases/validations/absence_validation_test.rb +++ b/activerecord/test/cases/validations/absence_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/face" require "models/interest" @@ -11,8 +13,8 @@ class AbsenceValidationTest < ActiveRecord::TestCase validates_absence_of :name end - assert boy_klass.new.valid? - assert_not boy_klass.new(name: "Alex").valid? + assert_predicate boy_klass.new, :valid? + assert_not_predicate boy_klass.new(name: "Alex"), :valid? end def test_has_one_marked_for_destruction @@ -42,7 +44,7 @@ class AbsenceValidationTest < ActiveRecord::TestCase assert_not boy.valid?, "should not be valid if has_many association is present" i2.mark_for_destruction - assert boy.valid? + assert_predicate boy, :valid? end def test_does_not_call_to_a_on_associations @@ -63,11 +65,11 @@ class AbsenceValidationTest < ActiveRecord::TestCase Interest.validates_absence_of(:token) interest = Interest.create!(topic: "Thought Leadering") - assert interest.valid? + assert_predicate interest, :valid? interest.token = "tl" - assert interest.invalid? + assert_predicate interest, :invalid? end end end diff --git a/activerecord/test/cases/validations/association_validation_test.rb b/activerecord/test/cases/validations/association_validation_test.rb index f5ceb27d97..ce6d42b34b 100644 --- a/activerecord/test/cases/validations/association_validation_test.rb +++ b/activerecord/test/cases/validations/association_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" @@ -14,14 +16,14 @@ class AssociationValidationTest < ActiveRecord::TestCase Reply.validates_presence_of(:content) t = Topic.create("title" => "uhohuhoh", "content" => "whatever") t.replies << [r = Reply.new("title" => "A reply"), r2 = Reply.new("title" => "Another reply", "content" => "non-empty"), r3 = Reply.new("title" => "Yet another reply"), r4 = Reply.new("title" => "The last reply", "content" => "non-empty")] - assert !t.valid? - assert t.errors[:replies].any? + assert_not_predicate t, :valid? + assert_predicate t.errors[:replies], :any? assert_equal 1, r.errors.count # make sure all associated objects have been validated assert_equal 0, r2.errors.count assert_equal 1, r3.errors.count assert_equal 0, r4.errors.count r.content = r3.content = "non-empty" - assert t.valid? + assert_predicate t, :valid? end def test_validates_associated_one @@ -29,10 +31,10 @@ class AssociationValidationTest < ActiveRecord::TestCase Topic.validates_presence_of(:content) r = Reply.new("title" => "A reply", "content" => "with content!") r.topic = Topic.create("title" => "uhohuhoh") - assert !r.valid? - assert r.errors[:topic].any? + assert_not_predicate r, :valid? + assert_predicate r.errors[:topic], :any? r.topic.content = "non-empty" - assert r.valid? + assert_predicate r, :valid? end def test_validates_associated_marked_for_destruction @@ -40,9 +42,9 @@ class AssociationValidationTest < ActiveRecord::TestCase Reply.validates_presence_of(:content) t = Topic.new t.replies << Reply.new - assert t.invalid? + assert_predicate t, :invalid? t.replies.first.mark_for_destruction - assert t.valid? + assert_predicate t, :valid? end def test_validates_associated_without_marked_for_destruction @@ -54,7 +56,7 @@ class AssociationValidationTest < ActiveRecord::TestCase Topic.validates_associated(:replies) t = Topic.new t.define_singleton_method(:replies) { [reply.new] } - assert t.valid? + assert_predicate t, :valid? end def test_validates_associated_with_custom_message_using_quotes @@ -69,11 +71,11 @@ class AssociationValidationTest < ActiveRecord::TestCase def test_validates_associated_missing Reply.validates_presence_of(:topic) r = Reply.create("title" => "A reply", "content" => "with content!") - assert !r.valid? - assert r.errors[:topic].any? + assert_not_predicate r, :valid? + assert_predicate r.errors[:topic], :any? r.topic = Topic.first - assert r.valid? + assert_predicate r, :valid? end def test_validates_presence_of_belongs_to_association__parent_is_new_record diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb index a57065ba75..703c24b340 100644 --- a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb +++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" diff --git a/activerecord/test/cases/validations/i18n_validation_test.rb b/activerecord/test/cases/validations/i18n_validation_test.rb index fd88a3ea67..b7c52ea18c 100644 --- a/activerecord/test/cases/validations/i18n_validation_test.rb +++ b/activerecord/test/cases/validations/i18n_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" diff --git a/activerecord/test/cases/validations/length_validation_test.rb b/activerecord/test/cases/validations/length_validation_test.rb index ba45c6dcc1..1fbcdc271b 100644 --- a/activerecord/test/cases/validations/length_validation_test.rb +++ b/activerecord/test/cases/validations/length_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/owner" require "models/pet" @@ -15,48 +17,48 @@ class LengthValidationTest < ActiveRecord::TestCase def test_validates_size_of_association assert_nothing_raised { @owner.validates_size_of :pets, minimum: 1 } o = @owner.new("name" => "nopets") - assert !o.save - assert o.errors[:pets].any? + assert_not o.save + assert_predicate o.errors[:pets], :any? o.pets.build("name" => "apet") - assert o.valid? + assert_predicate o, :valid? end def test_validates_size_of_association_using_within assert_nothing_raised { @owner.validates_size_of :pets, within: 1..2 } o = @owner.new("name" => "nopets") - assert !o.save - assert o.errors[:pets].any? + assert_not o.save + assert_predicate o.errors[:pets], :any? o.pets.build("name" => "apet") - assert o.valid? + assert_predicate o, :valid? 2.times { o.pets.build("name" => "apet") } - assert !o.save - assert o.errors[:pets].any? + assert_not o.save + assert_predicate o.errors[:pets], :any? end def test_validates_size_of_association_utf8 @owner.validates_size_of :pets, minimum: 1 o = @owner.new("name" => "あいうえおかきくけこ") - assert !o.save - assert o.errors[:pets].any? + assert_not o.save + assert_predicate o.errors[:pets], :any? o.pets.build("name" => "あいうえおかきくけこ") - assert o.valid? + assert_predicate o, :valid? end def test_validates_size_of_respects_records_marked_for_destruction @owner.validates_size_of :pets, minimum: 1 owner = @owner.new assert_not owner.save - assert owner.errors[:pets].any? + assert_predicate owner.errors[:pets], :any? pet = owner.pets.build - assert owner.valid? + assert_predicate owner, :valid? assert owner.save pet_count = Pet.count - assert_not owner.update_attributes pets_attributes: [ { _destroy: 1, id: pet.id } ] - assert_not owner.valid? - assert owner.errors[:pets].any? + assert_not owner.update pets_attributes: [ { _destroy: 1, id: pet.id } ] + assert_not_predicate owner, :valid? + assert_predicate owner.errors[:pets], :any? assert_equal pet_count, Pet.count end @@ -68,11 +70,11 @@ class LengthValidationTest < ActiveRecord::TestCase pet = Pet.create!(name: "Fancy Pants", nickname: "Fancy") - assert pet.valid? + assert_predicate pet, :valid? pet.nickname = "" - assert pet.invalid? + assert_predicate pet, :invalid? end end end diff --git a/activerecord/test/cases/validations/presence_validation_test.rb b/activerecord/test/cases/validations/presence_validation_test.rb index 13956e26ec..63c3f67da2 100644 --- a/activerecord/test/cases/validations/presence_validation_test.rb +++ b/activerecord/test/cases/validations/presence_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/man" require "models/face" @@ -13,10 +15,10 @@ class PresenceValidationTest < ActiveRecord::TestCase def test_validates_presence_of_non_association Boy.validates_presence_of(:name) b = Boy.new - assert b.invalid? + assert_predicate b, :invalid? b.name = "Alex" - assert b.valid? + assert_predicate b, :valid? end def test_validates_presence_of_has_one @@ -31,23 +33,23 @@ class PresenceValidationTest < ActiveRecord::TestCase b = Boy.new f = Face.new b.face = f - assert b.valid? + assert_predicate b, :valid? f.mark_for_destruction - assert b.invalid? + assert_predicate b, :invalid? end def test_validates_presence_of_has_many_marked_for_destruction Boy.validates_presence_of(:interests) b = Boy.new b.interests << [i1 = Interest.new, i2 = Interest.new] - assert b.valid? + assert_predicate b, :valid? i1.mark_for_destruction - assert b.valid? + assert_predicate b, :valid? i2.mark_for_destruction - assert b.invalid? + assert_predicate b, :invalid? end def test_validates_presence_doesnt_convert_to_array @@ -72,11 +74,11 @@ class PresenceValidationTest < ActiveRecord::TestCase Interest.validates_presence_of(:abbreviation) interest = Interest.create!(topic: "Thought Leadering", abbreviation: "tl") - assert interest.valid? + assert_predicate interest, :valid? interest.abbreviation = "" - assert interest.invalid? + assert_predicate interest, :invalid? end end diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb index 277280b42e..8f6f47e5fb 100644 --- a/activerecord/test/cases/validations/uniqueness_validation_test.rb +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" @@ -60,7 +62,7 @@ class TopicWithAfterCreate < Topic after_create :set_author def set_author - update_attributes!(author_name: "#{title} #{id}") + update!(author_name: "#{title} #{id}") end end @@ -81,8 +83,8 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert t.save, "Should still save t as unique" t2 = Topic.new("title" => "I'm uniqué!") - assert !t2.valid?, "Shouldn't be valid" - assert !t2.save, "Shouldn't save t2 as unique" + assert_not t2.valid?, "Shouldn't be valid" + assert_not t2.save, "Shouldn't save t2 as unique" assert_equal ["has already been taken"], t2.errors[:title] t2.title = "Now I am really also unique" @@ -94,7 +96,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase Topic.validates_uniqueness_of(:new_title) topic = Topic.new(new_title: "abc") - assert topic.valid? + assert_predicate topic, :valid? end def test_validates_uniqueness_with_nil_value @@ -104,8 +106,8 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert t.save, "Should save t as unique" t2 = Topic.new("title" => nil) - assert !t2.valid?, "Shouldn't be valid" - assert !t2.save, "Shouldn't save t2 as unique" + assert_not t2.valid?, "Shouldn't be valid" + assert_not t2.save, "Shouldn't save t2 as unique" assert_equal ["has already been taken"], t2.errors[:title] end @@ -114,7 +116,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase Topic.create!("title" => "abc") t2 = Topic.new("title" => "abc") - assert !t2.valid? + assert_not_predicate t2, :valid? assert t2.errors[:title] end @@ -144,7 +146,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r1.valid?, "Saving r1" r2 = t.replies.create "title" => "r2", "content" => "hello world" - assert !r2.valid?, "Saving r2 first time" + assert_not r2.valid?, "Saving r2 first time" r2.content = "something else" assert r2.save, "Saving r2 second time" @@ -154,6 +156,13 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r3.valid?, "Saving r3" end + def test_validate_uniqueness_with_scope_invalid_syntax + error = assert_raises(ArgumentError) do + Reply.validates_uniqueness_of(:content, scope: { parent_id: false }) + end + assert_match(/Pass a symbol or an array of symbols instead/, error.to_s) + end + def test_validate_uniqueness_with_object_scope Reply.validates_uniqueness_of(:content, scope: :topic) @@ -163,7 +172,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r1.valid?, "Saving r1" r2 = t.replies.create "title" => "r2", "content" => "hello world" - assert !r2.valid?, "Saving r2 first time" + assert_not r2.valid?, "Saving r2 first time" end def test_validate_uniqueness_with_polymorphic_object_scope @@ -184,7 +193,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r1.valid?, "Saving r1" r2 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world" - assert !r2.valid?, "Saving r2 first time" + assert_not r2.valid?, "Saving r2 first time" end def test_validate_uniqueness_with_object_arg @@ -196,7 +205,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r1.valid?, "Saving r1" r2 = t.replies.create "title" => "r2", "content" => "hello world" - assert !r2.valid?, "Saving r2 first time" + assert_not r2.valid?, "Saving r2 first time" end def test_validate_uniqueness_scoped_to_defining_class @@ -206,7 +215,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r1.valid?, "Saving r1" r2 = t.silly_unique_replies.create "title" => "r2", "content" => "a barrel of fun" - assert !r2.valid?, "Saving r2" + assert_not r2.valid?, "Saving r2" # Should succeed as validates_uniqueness_of only applies to # UniqueReply and its subclasses @@ -223,19 +232,19 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert r1.valid?, "Saving r1" r2 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply again..." - assert !r2.valid?, "Saving r2. Double reply by same author." + assert_not r2.valid?, "Saving r2. Double reply by same author." r2.author_email_address = "jeremy_alt_email@rubyonrails.com" assert r2.save, "Saving r2 the second time." r3 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy_alt_email@rubyonrails.com", "title" => "You're wrong", "content" => "It's cubic" - assert !r3.valid?, "Saving r3" + assert_not r3.valid?, "Saving r3" r3.author_name = "jj" assert r3.save, "Saving r3 the second time." r3.author_name = "jeremy" - assert !r3.save, "Saving r3 the third time." + assert_not r3.save, "Saving r3 the third time." end def test_validate_case_insensitive_uniqueness @@ -248,17 +257,17 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert t.save, "Should still save t as unique" t2 = Topic.new("title" => "I'm UNIQUE!", :parent_id => 1) - assert !t2.valid?, "Shouldn't be valid" - assert !t2.save, "Shouldn't save t2 as unique" - assert t2.errors[:title].any? - assert t2.errors[:parent_id].any? + assert_not t2.valid?, "Shouldn't be valid" + assert_not t2.save, "Shouldn't save t2 as unique" + assert_predicate t2.errors[:title], :any? + assert_predicate t2.errors[:parent_id], :any? assert_equal ["has already been taken"], t2.errors[:title] t2.title = "I'm truly UNIQUE!" - assert !t2.valid?, "Shouldn't be valid" - assert !t2.save, "Shouldn't save t2 as unique" - assert t2.errors[:title].empty? - assert t2.errors[:parent_id].any? + assert_not t2.valid?, "Shouldn't be valid" + assert_not t2.save, "Shouldn't save t2 as unique" + assert_empty t2.errors[:title] + assert_predicate t2.errors[:parent_id], :any? t2.parent_id = 4 assert t2.save, "Should now save t2 as unique" @@ -274,8 +283,8 @@ class UniquenessValidationTest < ActiveRecord::TestCase # If database hasn't UTF-8 character set, this test fails if Topic.all.merge!(select: "LOWER(title) AS title").find(t_utf8.id).title == "я тоже уникальный!" t2_utf8 = Topic.new("title" => "я тоже УНИКАЛЬНЫЙ!") - assert !t2_utf8.valid?, "Shouldn't be valid" - assert !t2_utf8.save, "Shouldn't save t2_utf8 as unique" + assert_not t2_utf8.valid?, "Shouldn't be valid" + assert_not t2_utf8.save, "Shouldn't save t2_utf8 as unique" end end @@ -317,15 +326,15 @@ class UniquenessValidationTest < ActiveRecord::TestCase t2 = Topic.new("title" => "I'M UNIQUE!") assert t2.valid?, "Should be valid" assert t2.save, "Should save t2 as unique" - assert t2.errors[:title].empty? - assert t2.errors[:parent_id].empty? + assert_empty t2.errors[:title] + assert_empty t2.errors[:parent_id] assert_not_equal ["has already been taken"], t2.errors[:title] t3 = Topic.new("title" => "I'M uNiQUe!") assert t3.valid?, "Should be valid" assert t3.save, "Should save t2 as unique" - assert t3.errors[:title].empty? - assert t3.errors[:parent_id].empty? + assert_empty t3.errors[:title] + assert_empty t3.errors[:parent_id] assert_not_equal ["has already been taken"], t3.errors[:title] end @@ -334,13 +343,13 @@ class UniquenessValidationTest < ActiveRecord::TestCase Topic.create!("title" => 101) t2 = Topic.new("title" => 101) - assert !t2.valid? + assert_not_predicate t2, :valid? assert t2.errors[:title] end def test_validate_uniqueness_with_non_standard_table_names i1 = WarehouseThing.create(value: 1000) - assert !i1.valid?, "i1 should not be valid" + assert_not i1.valid?, "i1 should not be valid" assert i1.errors[:value].any?, "Should not be empty" end @@ -351,7 +360,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase t1 = Topic.new("title" => "I'm unique!", "author_name" => "Mary") assert t1.save t2 = Topic.new("title" => "I'm unique!", "author_name" => "David") - assert !t2.valid? + assert_not_predicate t2, :valid? end end @@ -385,7 +394,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_with_limit_and_utf8 if current_adapter?(:SQLite3Adapter) - # Event.title has limit 5, but does SQLite doesn't truncate. + # Event.title has limit 5, but SQLite doesn't truncate. e1 = Event.create(title: "一二三四五六七八") assert e1.valid?, "Could not create an event with a unique 8 characters title" @@ -408,12 +417,12 @@ class UniquenessValidationTest < ActiveRecord::TestCase # Should use validation from base class (which is abstract) w2 = IneptWizard.new(name: "Rincewind", city: "Quirm") - assert !w2.valid?, "w2 shouldn't be valid" + assert_not w2.valid?, "w2 shouldn't be valid" assert w2.errors[:name].any?, "Should have errors for name" assert_equal ["has already been taken"], w2.errors[:name], "Should have uniqueness message for name" w3 = Conjurer.new(name: "Rincewind", city: "Quirm") - assert !w3.valid?, "w3 shouldn't be valid" + assert_not w3.valid?, "w3 shouldn't be valid" assert w3.errors[:name].any?, "Should have errors for name" assert_equal ["has already been taken"], w3.errors[:name], "Should have uniqueness message for name" @@ -421,12 +430,12 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert w4.valid?, "Saving w4" w5 = Thaumaturgist.new(name: "The Amazing Bonko", city: "Lancre") - assert !w5.valid?, "w5 shouldn't be valid" + assert_not w5.valid?, "w5 shouldn't be valid" assert w5.errors[:name].any?, "Should have errors for name" assert_equal ["has already been taken"], w5.errors[:name], "Should have uniqueness message for name" w6 = Thaumaturgist.new(name: "Mustrum Ridcully", city: "Quirm") - assert !w6.valid?, "w6 shouldn't be valid" + assert_not w6.valid?, "w6 shouldn't be valid" assert w6.errors[:city].any?, "Should have errors for city" assert_equal ["has already been taken"], w6.errors[:city], "Should have uniqueness message for city" end @@ -437,7 +446,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase Topic.create("title" => "I'm an unapproved topic", "approved" => false) t3 = Topic.new("title" => "I'm a topic", "approved" => true) - assert !t3.valid?, "t3 shouldn't be valid" + assert_not t3.valid?, "t3 shouldn't be valid" t4 = Topic.new("title" => "I'm an unapproved topic", "approved" => false) assert t4.valid?, "t4 should be valid" @@ -451,16 +460,16 @@ class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_on_existing_relation event = Event.create - assert TopicWithUniqEvent.create(event: event).valid? + assert_predicate TopicWithUniqEvent.create(event: event), :valid? topic = TopicWithUniqEvent.new(event: event) - assert_not topic.valid? + assert_not_predicate topic, :valid? assert_equal ["has already been taken"], topic.errors[:event] end def test_validate_uniqueness_on_empty_relation topic = TopicWithUniqEvent.new - assert topic.valid? + assert_predicate topic, :valid? end def test_validate_uniqueness_of_custom_primary_key @@ -479,7 +488,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase key2 = klass.create!(key_number: 11) key2.key_number = 10 - assert_not key2.valid? + assert_not_predicate key2, :valid? end def test_validate_uniqueness_without_primary_key @@ -492,8 +501,8 @@ class UniquenessValidationTest < ActiveRecord::TestCase end abc = klass.create!(dashboard_id: "abc") - assert klass.new(dashboard_id: "xyz").valid? - assert_not klass.new(dashboard_id: "abc").valid? + assert_predicate klass.new(dashboard_id: "xyz"), :valid? + assert_not_predicate klass.new(dashboard_id: "abc"), :valid? abc.dashboard_id = "def" @@ -521,7 +530,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert topic.author_name.start_with?("Title1") topic2 = TopicWithAfterCreate.new(title: "Title1") - refute topic2.valid? + assert_not_predicate topic2, :valid? assert_equal(["has already been taken"], topic2.errors[:title]) end @@ -541,7 +550,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert_empty item.errors item2 = CoolTopic.new(id: item.id, title: "MyItem2") - refute item2.valid? + assert_not_predicate item2, :valid? assert_equal(["has already been taken"], item2.errors[:id]) end diff --git a/activerecord/test/cases/validations_repair_helper.rb b/activerecord/test/cases/validations_repair_helper.rb index b30666d876..6dc3b64b2b 100644 --- a/activerecord/test/cases/validations_repair_helper.rb +++ b/activerecord/test/cases/validations_repair_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveRecord module ValidationsRepairHelper extend ActiveSupport::Concern diff --git a/activerecord/test/cases/validations_test.rb b/activerecord/test/cases/validations_test.rb index 5d9aa99497..66763c727f 100644 --- a/activerecord/test/cases/validations_test.rb +++ b/activerecord/test/cases/validations_test.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" -require "models/person" require "models/developer" require "models/computer" require "models/parrot" require "models/company" +require "models/price_estimate" class ValidationsTest < ActiveRecord::TestCase fixtures :topics, :developers @@ -17,7 +19,7 @@ class ValidationsTest < ActiveRecord::TestCase def test_valid_uses_create_context_when_new r = WrongReply.new r.title = "Wrong Create" - assert_not r.valid? + assert_not_predicate r, :valid? assert r.errors[:title].any?, "A reply with a bad title should mark that attribute as invalid" assert_equal ["is Wrong Create"], r.errors[:title], "A reply with a bad content should contain an error" end @@ -37,7 +39,7 @@ class ValidationsTest < ActiveRecord::TestCase def test_valid_using_special_context r = WrongReply.new(title: "Valid title") - assert !r.valid?(:special_case) + assert_not r.valid?(:special_case) assert_equal "Invalid", r.errors[:author_name].join r.author_name = "secret" @@ -123,7 +125,7 @@ class ValidationsTest < ActiveRecord::TestCase def test_save_without_validation reply = WrongReply.new - assert !reply.save + assert_not reply.save assert reply.save(validate: false) end @@ -137,7 +139,7 @@ class ValidationsTest < ActiveRecord::TestCase def test_throw_away_typing d = Developer.new("name" => "David", "salary" => "100,000") - assert !d.valid? + assert_not_predicate d, :valid? assert_equal 100, d.salary assert_equal "100,000", d.salary_before_type_cast end @@ -164,7 +166,37 @@ class ValidationsTest < ActiveRecord::TestCase topic = klass.new(wibble: "123-4567") topic.wibble.gsub!("-", "") - assert topic.valid? + assert_predicate topic, :valid? + end + + def test_numericality_validation_checks_against_raw_value + klass = Class.new(Topic) do + def self.model_name + ActiveModel::Name.new(self, nil, "Topic") + end + attribute :wibble, :decimal, scale: 2, precision: 9 + validates_numericality_of :wibble, greater_than_or_equal_to: BigDecimal("97.18") + end + + assert_not_predicate klass.new(wibble: "97.179"), :valid? + assert_not_predicate klass.new(wibble: 97.179), :valid? + assert_not_predicate klass.new(wibble: BigDecimal("97.179")), :valid? + end + + def test_numericality_validator_wont_be_affected_by_custom_getter + price_estimate = PriceEstimate.new(price: 50) + + assert_equal "$50.00", price_estimate.price + assert_equal 50, price_estimate.price_before_type_cast + assert_equal 50, price_estimate.read_attribute(:price) + + assert_predicate price_estimate, :price_came_from_user? + assert_predicate price_estimate, :valid? + + price_estimate.save! + + assert_not_predicate price_estimate, :price_came_from_user? + assert_predicate price_estimate, :valid? end def test_acceptance_validator_doesnt_require_db_connection diff --git a/activerecord/test/cases/view_test.rb b/activerecord/test/cases/view_test.rb index 07288568e8..7e2d66c62a 100644 --- a/activerecord/test/cases/view_test.rb +++ b/activerecord/test/cases/view_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/book" require "support/schema_dumping_helper" @@ -154,7 +156,9 @@ if ActiveRecord::Base.connection.supports_views? end # sqlite dose not support CREATE, INSERT, and DELETE for VIEW - if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :SQLServerAdapter) + if current_adapter?(:Mysql2Adapter, :SQLServerAdapter) || + current_adapter?(:PostgreSQLAdapter) && ActiveRecord::Base.connection.postgresql_version >= 90300 + class UpdateableViewTest < ActiveRecord::TestCase self.use_transactional_tests = false fixtures :books diff --git a/activerecord/test/cases/yaml_serialization_test.rb b/activerecord/test/cases/yaml_serialization_test.rb index 1571b31329..60ebdce178 100644 --- a/activerecord/test/cases/yaml_serialization_test.rb +++ b/activerecord/test/cases/yaml_serialization_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "cases/helper" require "models/topic" require "models/reply" @@ -5,7 +7,7 @@ require "models/post" require "models/author" class YamlSerializationTest < ActiveRecord::TestCase - fixtures :topics, :authors, :posts + fixtures :topics, :authors, :author_addresses, :posts def test_to_yaml_with_time_with_zone_should_not_raise_exception with_timezone_config aware_attributes: true, zone: "Pacific Time (US & Canada)" do @@ -94,7 +96,7 @@ class YamlSerializationTest < ActiveRecord::TestCase def test_deserializing_rails_41_yaml topic = YAML.load(yaml_fixture("rails_4_1")) - assert topic.new_record? + assert_predicate topic, :new_record? assert_nil topic.id assert_equal "The First Topic", topic.title assert_equal({ omg: :lol }, topic.content) @@ -103,7 +105,7 @@ class YamlSerializationTest < ActiveRecord::TestCase def test_deserializing_rails_4_2_0_yaml topic = YAML.load(yaml_fixture("rails_4_2_0")) - assert_not topic.new_record? + assert_not_predicate topic, :new_record? assert_equal 1, topic.id assert_equal "The First Topic", topic.title assert_equal("Have a nice day", topic.content) @@ -119,12 +121,20 @@ class YamlSerializationTest < ActiveRecord::TestCase assert_equal author.changes, dumped.changes end + def test_yaml_encoding_keeps_false_values + topic = Topic.first + topic.approved = false + dumped = YAML.load(YAML.dump(topic)) + + assert_equal false, dumped.approved + end + private def yaml_fixture(file_name) path = File.expand_path( - "../../support/yaml_compatibility_fixtures/#{file_name}.yml", - __FILE__ + "../support/yaml_compatibility_fixtures/#{file_name}.yml", + __dir__ ) File.read(path) end diff --git a/activerecord/test/config.example.yml b/activerecord/test/config.example.yml index 4bcb2aeea6..be337ddcd8 100644 --- a/activerecord/test/config.example.yml +++ b/activerecord/test/config.example.yml @@ -54,11 +54,11 @@ connections: mysql2: arunit: username: rails - encoding: utf8 - collation: utf8_unicode_ci + encoding: utf8mb4 + collation: utf8mb4_unicode_ci arunit2: username: rails - encoding: utf8 + encoding: utf8mb4 oracle: arunit: diff --git a/activerecord/test/config.rb b/activerecord/test/config.rb index 6e2e8b2145..72cdfb16ef 100644 --- a/activerecord/test/config.rb +++ b/activerecord/test/config.rb @@ -1,4 +1,6 @@ -TEST_ROOT = File.expand_path(File.dirname(__FILE__)) +# frozen_string_literal: true + +TEST_ROOT = __dir__ ASSETS_ROOT = TEST_ROOT + "/assets" FIXTURES_ROOT = TEST_ROOT + "/fixtures" MIGRATIONS_ROOT = TEST_ROOT + "/migrations" diff --git a/activerecord/test/fixtures/.gitignore b/activerecord/test/fixtures/.gitignore deleted file mode 100644 index 885029a512..0000000000 --- a/activerecord/test/fixtures/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite*
\ No newline at end of file diff --git a/activerecord/test/fixtures/binaries.yml b/activerecord/test/fixtures/binaries.yml index ec8f2facdc..53b7883369 100644 --- a/activerecord/test/fixtures/binaries.yml +++ b/activerecord/test/fixtures/binaries.yml @@ -131,3 +131,7 @@ flowers: SgCUASgCUASgCUASgAC74PbXOTvE5/En7jpSoLE8/wBn7uPJjKyj46T9D/NT pKsXyQzxNpdNP0/akB5484WkMKh4RfXG4UafNmH7b0UxWMrb7Nxg6rl9Z/Im w+vWq0iscQwxQroiUIvkKsRZQBKAJQBKAJQB/9k= + +binary_helper: + id: 2 + data: <%= binary(ASSETS_ROOT + "/flowers.jpg") %> diff --git a/activerecord/test/fixtures/books.yml b/activerecord/test/fixtures/books.yml index b3625ee72e..699623a6f9 100644 --- a/activerecord/test/fixtures/books.yml +++ b/activerecord/test/fixtures/books.yml @@ -25,6 +25,7 @@ ddd: name: "Domain-Driven Design" format: "hardcover" status: 2 + read_status: "forgotten" tlg: author_id: 1 diff --git a/activerecord/test/fixtures/customers.yml b/activerecord/test/fixtures/customers.yml index 0399ff83b9..7d6c1366d0 100644 --- a/activerecord/test/fixtures/customers.yml +++ b/activerecord/test/fixtures/customers.yml @@ -23,4 +23,13 @@ barney: address_street: Quiet Road address_city: Peaceful Town address_country: Tranquil Land - gps_location: NULL
\ No newline at end of file + gps_location: NULL + +mary: + id: 4 + name: Mary + balance: 1 + address_street: Funny Street + address_city: Peaceful Town + address_country: Nation Land + gps_location: NULL diff --git a/activerecord/test/fixtures/memberships.yml b/activerecord/test/fixtures/memberships.yml index a5d52bd438..f7ca227533 100644 --- a/activerecord/test/fixtures/memberships.yml +++ b/activerecord/test/fixtures/memberships.yml @@ -26,6 +26,13 @@ blarpy_winkup_crazy_club: favourite: false type: CurrentMembership +super_membership_of_boring_club: + joined_on: <%= 3.weeks.ago.to_s(:db) %> + club: boring_club + member_id: 1 + favourite: false + type: SuperMembership + selected_membership_of_boring_club: joined_on: <%= 3.weeks.ago.to_s(:db) %> club: boring_club diff --git a/activerecord/test/fixtures/minimalistics.yml b/activerecord/test/fixtures/minimalistics.yml index c3ec546209..83df0551bc 100644 --- a/activerecord/test/fixtures/minimalistics.yml +++ b/activerecord/test/fixtures/minimalistics.yml @@ -1,2 +1,5 @@ +zero: + id: 0 + first: id: 1 diff --git a/activerecord/test/fixtures/naked/yml/parrots.yml b/activerecord/test/fixtures/naked/yml/parrots.yml index 3e10331105..76f66e01ae 100644 --- a/activerecord/test/fixtures/naked/yml/parrots.yml +++ b/activerecord/test/fixtures/naked/yml/parrots.yml @@ -1,2 +1,3 @@ george: arrr: "Curious George" + foobar: Foobar diff --git a/activerecord/test/fixtures/other_posts.yml b/activerecord/test/fixtures/other_posts.yml index 39ff763547..3e11a33802 100644 --- a/activerecord/test/fixtures/other_posts.yml +++ b/activerecord/test/fixtures/other_posts.yml @@ -5,3 +5,4 @@ second_welcome: author_id: 1 title: Welcome to the another weblog body: It's really nice today + comments_count: 1 diff --git a/activerecord/test/fixtures/posts.yml b/activerecord/test/fixtures/posts.yml index 86d46f753a..8d7e1e0ae7 100644 --- a/activerecord/test/fixtures/posts.yml +++ b/activerecord/test/fixtures/posts.yml @@ -28,6 +28,7 @@ sti_comments: author_id: 1 title: sti comments body: hello + comments_count: 5 type: Post sti_post_and_comments: @@ -35,6 +36,7 @@ sti_post_and_comments: author_id: 1 title: sti me body: hello + comments_count: 2 type: StiPost sti_habtm: @@ -50,6 +52,8 @@ eager_other: title: eager loading with OR'd conditions body: hello type: Post + comments_count: 1 + tags_count: 3 misc_by_bob: id: 8 @@ -57,6 +61,7 @@ misc_by_bob: title: misc post by bob body: hello type: Post + tags_count: 1 misc_by_mary: id: 9 @@ -64,6 +69,7 @@ misc_by_mary: title: misc post by mary body: hello type: Post + tags_count: 1 other_by_bob: id: 10 @@ -71,6 +77,7 @@ other_by_bob: title: other post by bob body: hello type: Post + tags_count: 1 other_by_mary: id: 11 @@ -78,3 +85,4 @@ other_by_mary: title: other post by mary body: hello type: Post + tags_count: 1 diff --git a/activerecord/test/fixtures/reserved_words/values.yml b/activerecord/test/fixtures/reserved_words/values.yml index 7d109609ab..9ed9e5edc5 100644 --- a/activerecord/test/fixtures/reserved_words/values.yml +++ b/activerecord/test/fixtures/reserved_words/values.yml @@ -1,7 +1,7 @@ values1: - id: 1 + as: 1 group_id: 2 values2: - id: 2 + as: 2 group_id: 1 diff --git a/activerecord/test/fixtures/sponsors.yml b/activerecord/test/fixtures/sponsors.yml index 2da541c539..02ddb8dd38 100644 --- a/activerecord/test/fixtures/sponsors.yml +++ b/activerecord/test/fixtures/sponsors.yml @@ -10,3 +10,6 @@ crazy_club_sponsor_for_groucho: sponsor_club: crazy_club sponsorable_id: 3 sponsorable_type: Member +sponsor_for_author_david: + sponsorable_id: 1 + sponsorable_type: Author diff --git a/activerecord/test/fixtures/teapots.yml b/activerecord/test/fixtures/teapots.yml deleted file mode 100644 index ff515beb45..0000000000 --- a/activerecord/test/fixtures/teapots.yml +++ /dev/null @@ -1,3 +0,0 @@ -bob: - id: 1 - name: Bob diff --git a/activerecord/test/migrations/10_urban/9_add_expressions.rb b/activerecord/test/migrations/10_urban/9_add_expressions.rb index e908c9eabc..4b0d5fb6fa 100644 --- a/activerecord/test/migrations/10_urban/9_add_expressions.rb +++ b/activerecord/test/migrations/10_urban/9_add_expressions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddExpressions < ActiveRecord::Migration::Current def self.up create_table("expressions") do |t| diff --git a/activerecord/test/migrations/decimal/1_give_me_big_numbers.rb b/activerecord/test/migrations/decimal/1_give_me_big_numbers.rb index 43c79bc20b..7d4233fe31 100644 --- a/activerecord/test/migrations/decimal/1_give_me_big_numbers.rb +++ b/activerecord/test/migrations/decimal/1_give_me_big_numbers.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + class GiveMeBigNumbers < ActiveRecord::Migration::Current def self.up create_table :big_numbers do |table| table.column :bank_balance, :decimal, precision: 10, scale: 2 table.column :big_bank_balance, :decimal, precision: 15, scale: 2 - table.column :world_population, :decimal, precision: 10 + table.column :world_population, :decimal, precision: 20 table.column :my_house_population, :decimal, precision: 2 table.column :value_of_e, :decimal end diff --git a/activerecord/test/migrations/empty/.gitkeep b/activerecord/test/migrations/empty/.keep index e69de29bb2..e69de29bb2 100644 --- a/activerecord/test/migrations/empty/.gitkeep +++ b/activerecord/test/migrations/empty/.keep diff --git a/activerecord/test/migrations/magic/1_currencies_have_symbols.rb b/activerecord/test/migrations/magic/1_currencies_have_symbols.rb index d4b0e6cd95..2ba2875751 100644 --- a/activerecord/test/migrations/magic/1_currencies_have_symbols.rb +++ b/activerecord/test/migrations/magic/1_currencies_have_symbols.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # coding: ISO-8859-15 class CurrenciesHaveSymbols < ActiveRecord::Migration::Current diff --git a/activerecord/test/migrations/missing/1000_people_have_middle_names.rb b/activerecord/test/migrations/missing/1000_people_have_middle_names.rb index e046944e31..d3c9b127fb 100644 --- a/activerecord/test/migrations/missing/1000_people_have_middle_names.rb +++ b/activerecord/test/migrations/missing/1000_people_have_middle_names.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveMiddleNames < ActiveRecord::Migration::Current def self.up add_column "people", "middle_name", :string diff --git a/activerecord/test/migrations/missing/1_people_have_last_names.rb b/activerecord/test/migrations/missing/1_people_have_last_names.rb index 50fe2a9c8e..bd5f5ea11e 100644 --- a/activerecord/test/migrations/missing/1_people_have_last_names.rb +++ b/activerecord/test/migrations/missing/1_people_have_last_names.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveLastNames < ActiveRecord::Migration::Current def self.up add_column "people", "last_name", :string diff --git a/activerecord/test/migrations/missing/3_we_need_reminders.rb b/activerecord/test/migrations/missing/3_we_need_reminders.rb index d7c63ac892..4647268c6e 100644 --- a/activerecord/test/migrations/missing/3_we_need_reminders.rb +++ b/activerecord/test/migrations/missing/3_we_need_reminders.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WeNeedReminders < ActiveRecord::Migration::Current def self.up create_table("reminders") do |t| diff --git a/activerecord/test/migrations/missing/4_innocent_jointable.rb b/activerecord/test/migrations/missing/4_innocent_jointable.rb index bd3bf21576..8063bc0558 100644 --- a/activerecord/test/migrations/missing/4_innocent_jointable.rb +++ b/activerecord/test/migrations/missing/4_innocent_jointable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class InnocentJointable < ActiveRecord::Migration::Current def self.up create_table("people_reminders", id: false) do |t| diff --git a/activerecord/test/migrations/rename/1_we_need_things.rb b/activerecord/test/migrations/rename/1_we_need_things.rb index 9dce01acfd..8e71a1d996 100644 --- a/activerecord/test/migrations/rename/1_we_need_things.rb +++ b/activerecord/test/migrations/rename/1_we_need_things.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WeNeedThings < ActiveRecord::Migration::Current def self.up create_table("things") do |t| diff --git a/activerecord/test/migrations/rename/2_rename_things.rb b/activerecord/test/migrations/rename/2_rename_things.rb index cb8484e7dc..110fe3f0fa 100644 --- a/activerecord/test/migrations/rename/2_rename_things.rb +++ b/activerecord/test/migrations/rename/2_rename_things.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RenameThings < ActiveRecord::Migration::Current def self.up rename_table "things", "awesome_things" diff --git a/activerecord/test/migrations/to_copy/1_people_have_hobbies.rb b/activerecord/test/migrations/to_copy/1_people_have_hobbies.rb index 76734bcd7d..badccf65cc 100644 --- a/activerecord/test/migrations/to_copy/1_people_have_hobbies.rb +++ b/activerecord/test/migrations/to_copy/1_people_have_hobbies.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveHobbies < ActiveRecord::Migration::Current def self.up add_column "people", "hobbies", :text diff --git a/activerecord/test/migrations/to_copy/2_people_have_descriptions.rb b/activerecord/test/migrations/to_copy/2_people_have_descriptions.rb index 7f883dbb45..1d19d5d6f4 100644 --- a/activerecord/test/migrations/to_copy/2_people_have_descriptions.rb +++ b/activerecord/test/migrations/to_copy/2_people_have_descriptions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveDescriptions < ActiveRecord::Migration::Current def self.up add_column "people", "description", :text diff --git a/activerecord/test/migrations/to_copy2/1_create_articles.rb b/activerecord/test/migrations/to_copy2/1_create_articles.rb index 2e9f5ec6bc..85c166b319 100644 --- a/activerecord/test/migrations/to_copy2/1_create_articles.rb +++ b/activerecord/test/migrations/to_copy2/1_create_articles.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateArticles < ActiveRecord::Migration::Current def self.up end diff --git a/activerecord/test/migrations/to_copy2/2_create_comments.rb b/activerecord/test/migrations/to_copy2/2_create_comments.rb index d361847d4b..1d213a1705 100644 --- a/activerecord/test/migrations/to_copy2/2_create_comments.rb +++ b/activerecord/test/migrations/to_copy2/2_create_comments.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateComments < ActiveRecord::Migration::Current def self.up end diff --git a/activerecord/test/migrations/to_copy_with_name_collision/1_people_have_hobbies.rb b/activerecord/test/migrations/to_copy_with_name_collision/1_people_have_hobbies.rb index 1a863367dd..d9fef596f5 100644 --- a/activerecord/test/migrations/to_copy_with_name_collision/1_people_have_hobbies.rb +++ b/activerecord/test/migrations/to_copy_with_name_collision/1_people_have_hobbies.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveHobbies < ActiveRecord::Migration::Current def self.up add_column "people", "hobbies", :string diff --git a/activerecord/test/migrations/to_copy_with_timestamps/20090101010101_people_have_hobbies.rb b/activerecord/test/migrations/to_copy_with_timestamps/20090101010101_people_have_hobbies.rb index 76734bcd7d..badccf65cc 100644 --- a/activerecord/test/migrations/to_copy_with_timestamps/20090101010101_people_have_hobbies.rb +++ b/activerecord/test/migrations/to_copy_with_timestamps/20090101010101_people_have_hobbies.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveHobbies < ActiveRecord::Migration::Current def self.up add_column "people", "hobbies", :text diff --git a/activerecord/test/migrations/to_copy_with_timestamps/20090101010202_people_have_descriptions.rb b/activerecord/test/migrations/to_copy_with_timestamps/20090101010202_people_have_descriptions.rb index 7f883dbb45..1d19d5d6f4 100644 --- a/activerecord/test/migrations/to_copy_with_timestamps/20090101010202_people_have_descriptions.rb +++ b/activerecord/test/migrations/to_copy_with_timestamps/20090101010202_people_have_descriptions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PeopleHaveDescriptions < ActiveRecord::Migration::Current def self.up add_column "people", "description", :text diff --git a/activerecord/test/migrations/to_copy_with_timestamps2/20090101010101_create_articles.rb b/activerecord/test/migrations/to_copy_with_timestamps2/20090101010101_create_articles.rb index 2e9f5ec6bc..85c166b319 100644 --- a/activerecord/test/migrations/to_copy_with_timestamps2/20090101010101_create_articles.rb +++ b/activerecord/test/migrations/to_copy_with_timestamps2/20090101010101_create_articles.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateArticles < ActiveRecord::Migration::Current def self.up end diff --git a/activerecord/test/migrations/to_copy_with_timestamps2/20090101010202_create_comments.rb b/activerecord/test/migrations/to_copy_with_timestamps2/20090101010202_create_comments.rb index d361847d4b..1d213a1705 100644 --- a/activerecord/test/migrations/to_copy_with_timestamps2/20090101010202_create_comments.rb +++ b/activerecord/test/migrations/to_copy_with_timestamps2/20090101010202_create_comments.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateComments < ActiveRecord::Migration::Current def self.up end diff --git a/activerecord/test/migrations/valid/1_valid_people_have_last_names.rb b/activerecord/test/migrations/valid/1_valid_people_have_last_names.rb index c450211d8c..3bedcdcdf0 100644 --- a/activerecord/test/migrations/valid/1_valid_people_have_last_names.rb +++ b/activerecord/test/migrations/valid/1_valid_people_have_last_names.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ValidPeopleHaveLastNames < ActiveRecord::Migration::Current def self.up add_column "people", "last_name", :string diff --git a/activerecord/test/migrations/valid/2_we_need_reminders.rb b/activerecord/test/migrations/valid/2_we_need_reminders.rb index d7c63ac892..4647268c6e 100644 --- a/activerecord/test/migrations/valid/2_we_need_reminders.rb +++ b/activerecord/test/migrations/valid/2_we_need_reminders.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WeNeedReminders < ActiveRecord::Migration::Current def self.up create_table("reminders") do |t| diff --git a/activerecord/test/migrations/valid/3_innocent_jointable.rb b/activerecord/test/migrations/valid/3_innocent_jointable.rb index bd3bf21576..8063bc0558 100644 --- a/activerecord/test/migrations/valid/3_innocent_jointable.rb +++ b/activerecord/test/migrations/valid/3_innocent_jointable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class InnocentJointable < ActiveRecord::Migration::Current def self.up create_table("people_reminders", id: false) do |t| diff --git a/activerecord/test/migrations/valid_with_subdirectories/1_valid_people_have_last_names.rb b/activerecord/test/migrations/valid_with_subdirectories/1_valid_people_have_last_names.rb index c450211d8c..3bedcdcdf0 100644 --- a/activerecord/test/migrations/valid_with_subdirectories/1_valid_people_have_last_names.rb +++ b/activerecord/test/migrations/valid_with_subdirectories/1_valid_people_have_last_names.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ValidPeopleHaveLastNames < ActiveRecord::Migration::Current def self.up add_column "people", "last_name", :string diff --git a/activerecord/test/migrations/valid_with_subdirectories/sub/2_we_need_reminders.rb b/activerecord/test/migrations/valid_with_subdirectories/sub/2_we_need_reminders.rb index d7c63ac892..4647268c6e 100644 --- a/activerecord/test/migrations/valid_with_subdirectories/sub/2_we_need_reminders.rb +++ b/activerecord/test/migrations/valid_with_subdirectories/sub/2_we_need_reminders.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WeNeedReminders < ActiveRecord::Migration::Current def self.up create_table("reminders") do |t| diff --git a/activerecord/test/migrations/valid_with_subdirectories/sub1/3_innocent_jointable.rb b/activerecord/test/migrations/valid_with_subdirectories/sub1/3_innocent_jointable.rb index bd3bf21576..8063bc0558 100644 --- a/activerecord/test/migrations/valid_with_subdirectories/sub1/3_innocent_jointable.rb +++ b/activerecord/test/migrations/valid_with_subdirectories/sub1/3_innocent_jointable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class InnocentJointable < ActiveRecord::Migration::Current def self.up create_table("people_reminders", id: false) do |t| diff --git a/activerecord/test/migrations/valid_with_timestamps/20100101010101_valid_with_timestamps_people_have_last_names.rb b/activerecord/test/migrations/valid_with_timestamps/20100101010101_valid_with_timestamps_people_have_last_names.rb index 9fd27593f0..b938847170 100644 --- a/activerecord/test/migrations/valid_with_timestamps/20100101010101_valid_with_timestamps_people_have_last_names.rb +++ b/activerecord/test/migrations/valid_with_timestamps/20100101010101_valid_with_timestamps_people_have_last_names.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ValidWithTimestampsPeopleHaveLastNames < ActiveRecord::Migration::Current def self.up add_column "people", "last_name", :string diff --git a/activerecord/test/migrations/valid_with_timestamps/20100201010101_valid_with_timestamps_we_need_reminders.rb b/activerecord/test/migrations/valid_with_timestamps/20100201010101_valid_with_timestamps_we_need_reminders.rb index 4a59921136..94551e8208 100644 --- a/activerecord/test/migrations/valid_with_timestamps/20100201010101_valid_with_timestamps_we_need_reminders.rb +++ b/activerecord/test/migrations/valid_with_timestamps/20100201010101_valid_with_timestamps_we_need_reminders.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ValidWithTimestampsWeNeedReminders < ActiveRecord::Migration::Current def self.up create_table("reminders") do |t| diff --git a/activerecord/test/migrations/valid_with_timestamps/20100301010101_valid_with_timestamps_innocent_jointable.rb b/activerecord/test/migrations/valid_with_timestamps/20100301010101_valid_with_timestamps_innocent_jointable.rb index be24de6d70..672edc5253 100644 --- a/activerecord/test/migrations/valid_with_timestamps/20100301010101_valid_with_timestamps_innocent_jointable.rb +++ b/activerecord/test/migrations/valid_with_timestamps/20100301010101_valid_with_timestamps_innocent_jointable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ValidWithTimestampsInnocentJointable < ActiveRecord::Migration::Current def self.up create_table("people_reminders", id: false) do |t| diff --git a/activerecord/test/migrations/version_check/20131219224947_migration_version_check.rb b/activerecord/test/migrations/version_check/20131219224947_migration_version_check.rb index 6f314c881c..91bfbbdfd1 100644 --- a/activerecord/test/migrations/version_check/20131219224947_migration_version_check.rb +++ b/activerecord/test/migrations/version_check/20131219224947_migration_version_check.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MigrationVersionCheck < ActiveRecord::Migration::Current def self.up raise "incorrect migration version" unless version == 20131219224947 diff --git a/activerecord/test/models/account.rb b/activerecord/test/models/account.rb new file mode 100644 index 0000000000..0c3cd45a81 --- /dev/null +++ b/activerecord/test/models/account.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Account < ActiveRecord::Base + belongs_to :firm, class_name: "Company" + belongs_to :unautosaved_firm, foreign_key: "firm_id", class_name: "Firm", autosave: false + + alias_attribute :available_credit, :credit_limit + + def self.destroyed_account_ids + @destroyed_account_ids ||= Hash.new { |h, k| h[k] = [] } + end + + # Test private kernel method through collection proxy using has_many. + def self.open + where("firm_name = ?", "37signals") + end + + before_destroy do |account| + if account.firm + Account.destroyed_account_ids[account.firm.id] << account.id + end + end + + validate :check_empty_credit_limit + + private + def check_empty_credit_limit + errors.add("credit_limit", :blank) if credit_limit.blank? + end + + def private_method + "Sir, yes sir!" + end +end diff --git a/activerecord/test/models/admin.rb b/activerecord/test/models/admin.rb index bc3ce23447..a40b5a33b2 100644 --- a/activerecord/test/models/admin.rb +++ b/activerecord/test/models/admin.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Admin def self.table_name_prefix "admin_" diff --git a/activerecord/test/models/admin/account.rb b/activerecord/test/models/admin/account.rb index bd23192d20..41fe2d782b 100644 --- a/activerecord/test/models/admin/account.rb +++ b/activerecord/test/models/admin/account.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Admin::Account < ActiveRecord::Base has_many :users end diff --git a/activerecord/test/models/admin/randomly_named_c1.rb b/activerecord/test/models/admin/randomly_named_c1.rb index b64ae7fc41..d89b8dd293 100644 --- a/activerecord/test/models/admin/randomly_named_c1.rb +++ b/activerecord/test/models/admin/randomly_named_c1.rb @@ -1,7 +1,9 @@ -class Admin::ClassNameThatDoesNotFollowCONVENTIONS1 < ActiveRecord::Base
- self.table_name = :randomly_named_table2
-end
-
-class Admin::ClassNameThatDoesNotFollowCONVENTIONS2 < ActiveRecord::Base
- self.table_name = :randomly_named_table3
-end
+# frozen_string_literal: true + +class Admin::ClassNameThatDoesNotFollowCONVENTIONS1 < ActiveRecord::Base + self.table_name = :randomly_named_table2 +end + +class Admin::ClassNameThatDoesNotFollowCONVENTIONS2 < ActiveRecord::Base + self.table_name = :randomly_named_table3 +end diff --git a/activerecord/test/models/admin/user.rb b/activerecord/test/models/admin/user.rb index a76e4b6795..691f9f11be 100644 --- a/activerecord/test/models/admin/user.rb +++ b/activerecord/test/models/admin/user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Admin::User < ActiveRecord::Base class Coder def initialize(default = {}) @@ -17,6 +19,12 @@ class Admin::User < ActiveRecord::Base store :params, accessors: [ :token ], coder: YAML store :settings, accessors: [ :color, :homepage ] store_accessor :settings, :favorite_food + store :parent, accessors: [:birthday, :name], prefix: true + store :spouse, accessors: [:birthday], prefix: :partner + store_accessor :spouse, :name, prefix: :partner + store :configs, accessors: [ :secret_question ] + store :configs, accessors: [ :two_factor_auth ], suffix: true + store_accessor :configs, :login_retry, suffix: :config store :preferences, accessors: [ :remember_login ] store :json_data, accessors: [ :height, :weight ], coder: Coder.new store :json_data_empty, accessors: [ :is_a_good_guy ], coder: Coder.new diff --git a/activerecord/test/models/aircraft.rb b/activerecord/test/models/aircraft.rb index ebd42ff824..4fdea46cf7 100644 --- a/activerecord/test/models/aircraft.rb +++ b/activerecord/test/models/aircraft.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Aircraft < ActiveRecord::Base self.pluralize_table_names = false has_many :engines, foreign_key: "car_id" diff --git a/activerecord/test/models/arunit2_model.rb b/activerecord/test/models/arunit2_model.rb index 04b8b15d3d..5b0da8a249 100644 --- a/activerecord/test/models/arunit2_model.rb +++ b/activerecord/test/models/arunit2_model.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ARUnit2Model < ActiveRecord::Base self.abstract_class = true end diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index fab613afd1..75932c7eb6 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Author < ActiveRecord::Base has_many :posts has_many :serialized_posts @@ -6,6 +8,7 @@ class Author < ActiveRecord::Base has_many :posts_with_comments, -> { includes(:comments) }, class_name: "Post" has_many :popular_grouped_posts, -> { includes(:comments).group("type").having("SUM(comments_count) > 1").select("type") }, class_name: "Post" has_many :posts_with_comments_sorted_by_comment_id, -> { includes(:comments).order("comments.id") }, class_name: "Post" + has_many :posts_sorted_by_id, -> { order(:id) }, class_name: "Post" has_many :posts_sorted_by_id_limited, -> { order("posts.id").limit(1) }, class_name: "Post" has_many :posts_with_categories, -> { includes(:categories) }, class_name: "Post" has_many :posts_with_comments_and_categories, -> { includes(:comments, :categories).order("posts.id") }, class_name: "Post" @@ -19,7 +22,8 @@ class Author < ActiveRecord::Base end has_many :comments_containing_the_letter_e, through: :posts, source: :comments has_many :comments_with_order_and_conditions, -> { order("comments.body").where("comments.body like 'Thank%'") }, through: :posts, source: :comments - has_many :comments_with_include, -> { includes(:post) }, through: :posts, source: :comments + has_many :comments_with_include, -> { includes(:post).where(posts: { type: "Post" }) }, through: :posts, source: :comments + has_many :comments_for_first_author, -> { for_first_author }, through: :posts, source: :comments has_many :first_posts has_many :comments_on_first_posts, -> { order("posts.id desc, comments.id asc") }, through: :first_posts, source: :comments @@ -37,7 +41,8 @@ class Author < ActiveRecord::Base -> { where(title: "Welcome to the weblog").where(Post.arel_table[:comments_count].gt(0)) }, class_name: "Post" - has_many :comments_desc, -> { order("comments.id DESC") }, through: :posts, source: :comments + has_many :comments_desc, -> { order("comments.id DESC") }, through: :posts_sorted_by_id, source: :comments + has_many :unordered_comments, -> { unscope(:order).distinct }, through: :posts_sorted_by_id_limited, source: :comments has_many :funky_comments, through: :posts, source: :comments has_many :ordered_uniq_comments, -> { distinct.order("comments.id") }, through: :posts, source: :comments has_many :ordered_uniq_comments_desc, -> { distinct.order("comments.id DESC") }, through: :posts, source: :comments @@ -76,7 +81,7 @@ class Author < ActiveRecord::Base after_add: [:log_after_adding, Proc.new { |o, r| o.post_log << "after_adding_proc#{r.id || '<new>'}" }] has_many :unchangeable_posts, class_name: "Post", before_add: :raise_exception, after_add: :log_after_adding - has_many :categorizations + has_many :categorizations, -> {} has_many :categories, through: :categorizations has_many :named_categories, through: :categorizations @@ -84,6 +89,9 @@ class Author < ActiveRecord::Base has_many :special_categories, through: :special_categorizations, source: :category has_one :special_category, through: :special_categorizations, source: :category + has_many :special_categories_with_conditions, -> { where(categorizations: { special: true }) }, through: :categorizations, source: :category + has_many :nonspecial_categories_with_conditions, -> { where(categorizations: { special: false }) }, through: :categorizations, source: :category + has_many :categories_like_general, -> { where(name: "General") }, through: :categorizations, source: :category, class_name: "Category" has_many :categorized_posts, through: :categorizations, source: :post @@ -97,15 +105,18 @@ class Author < ActiveRecord::Base has_many :taggings, through: :posts, source: :taggings has_many :taggings_2, through: :posts, source: :tagging has_many :tags, through: :posts + has_many :ordered_tags, through: :posts has_many :post_categories, through: :posts, source: :categories has_many :tagging_tags, through: :taggings, source: :tag has_many :similar_posts, -> { distinct }, through: :tags, source: :tagged_posts + has_many :ordered_posts, -> { distinct }, through: :ordered_tags, source: :tagged_posts has_many :distinct_tags, -> { select("DISTINCT tags.*").order("tags.name") }, through: :posts, source: :tags has_many :tags_with_primary_key, through: :posts has_many :books + has_many :unpublished_books, -> { where(status: [:proposed, :written]) }, class_name: "Book" has_many :subscriptions, through: :books has_many :subscribers, -> { order("subscribers.nick") }, through: :subscriptions has_many :distinct_subscribers, -> { select("DISTINCT subscribers.*").order("subscribers.nick") }, through: :subscriptions, source: :subscriber @@ -151,6 +162,9 @@ class Author < ActiveRecord::Base def extension_method; end end + has_many :top_posts, -> { order(id: :asc) }, class_name: "Post" + has_many :other_top_posts, -> { order(id: :asc) }, class_name: "Post" + attr_accessor :post_log after_initialize :set_post_log diff --git a/activerecord/test/models/auto_id.rb b/activerecord/test/models/auto_id.rb index 82c6544bd5..fd672603bb 100644 --- a/activerecord/test/models/auto_id.rb +++ b/activerecord/test/models/auto_id.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AutoId < ActiveRecord::Base self.table_name = "auto_id_tests" self.primary_key = "auto_id" diff --git a/activerecord/test/models/autoloadable/extra_firm.rb b/activerecord/test/models/autoloadable/extra_firm.rb index 5578ba0d9b..c46e34c101 100644 --- a/activerecord/test/models/autoloadable/extra_firm.rb +++ b/activerecord/test/models/autoloadable/extra_firm.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class ExtraFirm < Company end diff --git a/activerecord/test/models/binary.rb b/activerecord/test/models/binary.rb index 39b2f5090a..b93f87519f 100644 --- a/activerecord/test/models/binary.rb +++ b/activerecord/test/models/binary.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Binary < ActiveRecord::Base end diff --git a/activerecord/test/models/bird.rb b/activerecord/test/models/bird.rb index 24b839135d..be08636ac6 100644 --- a/activerecord/test/models/bird.rb +++ b/activerecord/test/models/bird.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Bird < ActiveRecord::Base belongs_to :pirate validates_presence_of :name diff --git a/activerecord/test/models/book.rb b/activerecord/test/models/book.rb index 17bf3fbcb4..afdda1a81e 100644 --- a/activerecord/test/models/book.rb +++ b/activerecord/test/models/book.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Book < ActiveRecord::Base - has_many :authors + belongs_to :author has_many :citations, foreign_key: "book1_id" has_many :references, -> { distinct }, through: :citations, source: :reference_of @@ -8,7 +10,7 @@ class Book < ActiveRecord::Base has_many :subscribers, through: :subscriptions enum status: [:proposed, :written, :published] - enum read_status: { unread: 0, reading: 2, read: 3 } + enum read_status: { unread: 0, reading: 2, read: 3, forgotten: nil } enum nullable_status: [:single, :married] enum language: [:english, :spanish, :french], _prefix: :in enum author_visibility: [:visible, :invisible], _prefix: true diff --git a/activerecord/test/models/boolean.rb b/activerecord/test/models/boolean.rb index 0da228aac2..bee757fb9c 100644 --- a/activerecord/test/models/boolean.rb +++ b/activerecord/test/models/boolean.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Boolean < ActiveRecord::Base def has_fun super diff --git a/activerecord/test/models/bulb.rb b/activerecord/test/models/bulb.rb index 113d21cb84..ab92f7025d 100644 --- a/activerecord/test/models/bulb.rb +++ b/activerecord/test/models/bulb.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Bulb < ActiveRecord::Base default_scope { where(name: "defaulty") } belongs_to :car, touch: true diff --git a/activerecord/test/models/cake_designer.rb b/activerecord/test/models/cake_designer.rb index 9c57ef573a..0b2a00edfd 100644 --- a/activerecord/test/models/cake_designer.rb +++ b/activerecord/test/models/cake_designer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CakeDesigner < ActiveRecord::Base has_one :chef, as: :employable end diff --git a/activerecord/test/models/car.rb b/activerecord/test/models/car.rb index 92bff7ff96..8614926626 100644 --- a/activerecord/test/models/car.rb +++ b/activerecord/test/models/car.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Car < ActiveRecord::Base has_many :bulbs has_many :all_bulbs, -> { unscope where: :name }, class_name: "Bulb" @@ -18,6 +20,8 @@ class Car < ActiveRecord::Base scope :incl_engines, -> { includes(:engines) } scope :order_using_new_style, -> { order("name asc") } + + attribute :wheels_owned_at, :datetime, default: -> { Time.now } end class CoolCar < Car diff --git a/activerecord/test/models/carrier.rb b/activerecord/test/models/carrier.rb index 230be118c3..995a9d3bef 100644 --- a/activerecord/test/models/carrier.rb +++ b/activerecord/test/models/carrier.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Carrier < ActiveRecord::Base end diff --git a/activerecord/test/models/cat.rb b/activerecord/test/models/cat.rb index dfdde18641..43013964b6 100644 --- a/activerecord/test/models/cat.rb +++ b/activerecord/test/models/cat.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cat < ActiveRecord::Base self.abstract_class = true diff --git a/activerecord/test/models/categorization.rb b/activerecord/test/models/categorization.rb index b99383d0b1..68b0ea90d3 100644 --- a/activerecord/test/models/categorization.rb +++ b/activerecord/test/models/categorization.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Categorization < ActiveRecord::Base belongs_to :post belongs_to :category, counter_cache: true diff --git a/activerecord/test/models/category.rb b/activerecord/test/models/category.rb index e8654dca01..2ccc00bed9 100644 --- a/activerecord/test/models/category.rb +++ b/activerecord/test/models/category.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Category < ActiveRecord::Base has_and_belongs_to_many :posts has_and_belongs_to_many :special_posts, class_name: "Post" @@ -29,6 +31,15 @@ class Category < ActiveRecord::Base has_many :authors_with_select, -> { select "authors.*, categorizations.post_id" }, through: :categorizations, source: :author scope :general, -> { where(name: "General") } + + # Should be delegated `ast` and `locked` to `arel`. + def self.ast + raise + end + + def self.locked + raise + end end class SpecialCategory < Category diff --git a/activerecord/test/models/chef.rb b/activerecord/test/models/chef.rb index 9d3dd01016..ff528644bc 100644 --- a/activerecord/test/models/chef.rb +++ b/activerecord/test/models/chef.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Chef < ActiveRecord::Base belongs_to :employable, polymorphic: true has_many :recipes diff --git a/activerecord/test/models/citation.rb b/activerecord/test/models/citation.rb index 7d06387f56..3d786f27eb 100644 --- a/activerecord/test/models/citation.rb +++ b/activerecord/test/models/citation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Citation < ActiveRecord::Base belongs_to :reference_of, class_name: "Book", foreign_key: :book2_id end diff --git a/activerecord/test/models/club.rb b/activerecord/test/models/club.rb index 49d7b24a3b..2006e05fcf 100644 --- a/activerecord/test/models/club.rb +++ b/activerecord/test/models/club.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Club < ActiveRecord::Base has_one :membership has_many :memberships, inverse_of: false @@ -8,6 +10,8 @@ class Club < ActiveRecord::Base has_many :favourites, -> { where(memberships: { favourite: true }) }, through: :memberships, source: :member + scope :general, -> { left_joins(:category).where(categories: { name: "General" }) } + private def private_method diff --git a/activerecord/test/models/college.rb b/activerecord/test/models/college.rb index c9dbe1ecc2..52017dda42 100644 --- a/activerecord/test/models/college.rb +++ b/activerecord/test/models/college.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_dependency "models/arunit2_model" require "active_support/core_ext/object/with_options" diff --git a/activerecord/test/models/column.rb b/activerecord/test/models/column.rb index 499358b4cf..d3cd419a00 100644 --- a/activerecord/test/models/column.rb +++ b/activerecord/test/models/column.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Column < ActiveRecord::Base belongs_to :record end diff --git a/activerecord/test/models/column_name.rb b/activerecord/test/models/column_name.rb index 460eb4fe20..c6047c507b 100644 --- a/activerecord/test/models/column_name.rb +++ b/activerecord/test/models/column_name.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ColumnName < ActiveRecord::Base self.table_name = "colnametests" end diff --git a/activerecord/test/models/comment.rb b/activerecord/test/models/comment.rb index a4b81d56e0..f0f0576709 100644 --- a/activerecord/test/models/comment.rb +++ b/activerecord/test/models/comment.rb @@ -1,3 +1,8 @@ +# frozen_string_literal: true + +# `counter_cache` requires association class before `attr_readonly`. +class Post < ActiveRecord::Base; end + class Comment < ActiveRecord::Base scope :limit_by, lambda { |l| limit(l) } scope :containing_the_letter_e, -> { where("comments.body LIKE '%e%'") } @@ -9,7 +14,6 @@ class Comment < ActiveRecord::Base belongs_to :post, counter_cache: true belongs_to :author, polymorphic: true belongs_to :resource, polymorphic: true - belongs_to :developer has_many :ratings @@ -19,6 +23,23 @@ class Comment < ActiveRecord::Base has_many :children, class_name: "Comment", foreign_key: :parent_id belongs_to :parent, class_name: "Comment", counter_cache: :children_count + class ::OopsError < RuntimeError; end + + module OopsExtension + def destroy_all(*) + raise OopsError + end + end + + default_scope { extending OopsExtension } + + scope :oops_comments, -> { extending OopsExtension } + + # Should not be called if extending modules that having the method exists on an association. + def self.greeting + raise + end + def self.what_are_you "a comment..." end @@ -38,6 +59,11 @@ class Comment < ActiveRecord::Base end class SpecialComment < Comment + default_scope { where(deleted_at: nil) } + + def self.what_are_you + "a special comment..." + end end class SubSpecialComment < SpecialComment @@ -50,7 +76,7 @@ class CommentThatAutomaticallyAltersPostBody < Comment belongs_to :post, class_name: "PostThatLoadsCommentsInAnAfterSaveHook", foreign_key: :post_id after_save do |comment| - comment.post.update_attributes(body: "Automatically altered") + comment.post.update(body: "Automatically altered") end end @@ -58,3 +84,9 @@ class CommentWithDefaultScopeReferencesAssociation < Comment default_scope -> { includes(:developer).order("developers.name").references(:developer) } belongs_to :developer end + +class CommentWithAfterCreateUpdate < Comment + after_create do + update(body: "bar") + end +end diff --git a/activerecord/test/models/company.rb b/activerecord/test/models/company.rb index 20e37710e7..485b35d58b 100644 --- a/activerecord/test/models/company.rb +++ b/activerecord/test/models/company.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AbstractCompany < ActiveRecord::Base self.abstract_class = true end @@ -85,6 +87,8 @@ class Firm < Company has_many :association_with_references, -> { references(:foo) }, class_name: "Client" + has_many :developers_with_select, -> { select("id, name, first_name") }, class_name: "Developer" + has_one :lead_developer, class_name: "Developer" has_many :projects @@ -141,6 +145,21 @@ class Client < Company raise RaisedOnSave if raise_on_save end + attr_accessor :throw_on_save + before_save do + throw :abort if throw_on_save + end + + attr_accessor :rollback_on_save + after_save do + raise ActiveRecord::Rollback if rollback_on_save + end + + attr_accessor :rollback_on_create_called + after_rollback(on: :create) do |client| + client.rollback_on_create_called = true + end + class RaisedOnDestroy < RuntimeError; end attr_accessor :raise_on_destroy before_destroy do @@ -170,19 +189,12 @@ class Client < Company def overwrite_to_raise end - - class << self - private - - def private_method - "darkness" - end - end end class ExclusivelyDependentFirm < Company has_one :account, foreign_key: "firm_id", dependent: :delete has_many :dependent_sanitized_conditional_clients_of_firm, -> { order("id").where("name = 'BigShot Inc.'") }, foreign_key: "client_of", class_name: "Client", dependent: :delete_all + has_many :dependent_hash_conditional_clients_of_firm, -> { order("id").where(name: "BigShot Inc.") }, foreign_key: "client_of", class_name: "Client", dependent: :delete_all has_many :dependent_conditional_clients_of_firm, -> { order("id").where("name = ?", "BigShot Inc.") }, foreign_key: "client_of", class_name: "Client", dependent: :delete_all end @@ -192,37 +204,12 @@ end class VerySpecialClient < SpecialClient end -class Account < ActiveRecord::Base - belongs_to :firm, class_name: "Company" - belongs_to :unautosaved_firm, foreign_key: "firm_id", class_name: "Firm", autosave: false +class NewlyContractedCompany < Company + has_many :new_contracts, foreign_key: "company_id" - alias_attribute :available_credit, :credit_limit - - def self.destroyed_account_ids - @destroyed_account_ids ||= Hash.new { |h, k| h[k] = [] } - end - - # Test private kernel method through collection proxy using has_many. - def self.open - where("firm_name = ?", "37signals") - end - - before_destroy do |account| - if account.firm - Account.destroyed_account_ids[account.firm.id] << account.id - end - true + before_save do + self.new_contracts << NewContract.new end - - validate :check_empty_credit_limit - - private - - def check_empty_credit_limit - errors.add("credit_limit", :blank) if credit_limit.blank? - end - - def private_method - "Sir, yes sir!" - end end + +require "models/account" diff --git a/activerecord/test/models/company_in_module.rb b/activerecord/test/models/company_in_module.rb index 0782c1eff4..52b7e06a63 100644 --- a/activerecord/test/models/company_in_module.rb +++ b/activerecord/test/models/company_in_module.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/object/with_options" module MyApplication diff --git a/activerecord/test/models/computer.rb b/activerecord/test/models/computer.rb index 1c9856e1af..582b4a38b5 100644 --- a/activerecord/test/models/computer.rb +++ b/activerecord/test/models/computer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Computer < ActiveRecord::Base belongs_to :developer, foreign_key: "developer" end diff --git a/activerecord/test/models/contact.rb b/activerecord/test/models/contact.rb index 47bbbbfd8b..6e02ff199b 100644 --- a/activerecord/test/models/contact.rb +++ b/activerecord/test/models/contact.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ContactFakeColumns def self.extended(base) base.class_eval do diff --git a/activerecord/test/models/content.rb b/activerecord/test/models/content.rb index 68db2127d8..14bbee53d8 100644 --- a/activerecord/test/models/content.rb +++ b/activerecord/test/models/content.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Content < ActiveRecord::Base self.table_name = "content" has_one :content_position, dependent: :destroy diff --git a/activerecord/test/models/contract.rb b/activerecord/test/models/contract.rb index 32bd581377..3f663375c4 100644 --- a/activerecord/test/models/contract.rb +++ b/activerecord/test/models/contract.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Contract < ActiveRecord::Base belongs_to :company - belongs_to :developer + belongs_to :developer, primary_key: :id belongs_to :firm, foreign_key: "company_id" before_save :hi @@ -18,3 +20,7 @@ class Contract < ActiveRecord::Base @bye_count += 1 end end + +class NewContract < Contract + validates :company_id, presence: true +end diff --git a/activerecord/test/models/country.rb b/activerecord/test/models/country.rb index 7912719ddd..0c84a40de2 100644 --- a/activerecord/test/models/country.rb +++ b/activerecord/test/models/country.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Country < ActiveRecord::Base self.primary_key = :country_id diff --git a/activerecord/test/models/course.rb b/activerecord/test/models/course.rb index 348f2bf1e0..4f346124ea 100644 --- a/activerecord/test/models/course.rb +++ b/activerecord/test/models/course.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_dependency "models/arunit2_model" class Course < ARUnit2Model diff --git a/activerecord/test/models/customer.rb b/activerecord/test/models/customer.rb index 3d40cb1ace..bc501a5ce0 100644 --- a/activerecord/test/models/customer.rb +++ b/activerecord/test/models/customer.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + class Customer < ActiveRecord::Base cattr_accessor :gps_conversion_was_run composed_of :address, mapping: [ %w(address_street street), %w(address_city city), %w(address_country country) ], allow_nil: true - composed_of :balance, class_name: "Money", mapping: %w(balance amount), converter: Proc.new(&:to_money) + composed_of :balance, class_name: "Money", mapping: %w(balance amount) composed_of :gps_location, allow_nil: true composed_of :non_blank_gps_location, class_name: "GpsLocation", allow_nil: true, mapping: %w(gps_location gps_location), converter: lambda { |gps| self.gps_conversion_was_run = true; gps.blank? ? nil : GpsLocation.new(gps) } diff --git a/activerecord/test/models/customer_carrier.rb b/activerecord/test/models/customer_carrier.rb index 37186903ff..6cb9d5239d 100644 --- a/activerecord/test/models/customer_carrier.rb +++ b/activerecord/test/models/customer_carrier.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CustomerCarrier < ActiveRecord::Base cattr_accessor :current_customer diff --git a/activerecord/test/models/dashboard.rb b/activerecord/test/models/dashboard.rb index 1b3b54545f..d25ceeafb1 100644 --- a/activerecord/test/models/dashboard.rb +++ b/activerecord/test/models/dashboard.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dashboard < ActiveRecord::Base self.primary_key = :dashboard_id end diff --git a/activerecord/test/models/default.rb b/activerecord/test/models/default.rb index 887e9cc999..90f1046d87 100644 --- a/activerecord/test/models/default.rb +++ b/activerecord/test/models/default.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Default < ActiveRecord::Base end diff --git a/activerecord/test/models/department.rb b/activerecord/test/models/department.rb index 08004a0ed3..868b9bf4bf 100644 --- a/activerecord/test/models/department.rb +++ b/activerecord/test/models/department.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Department < ActiveRecord::Base has_many :chefs belongs_to :hotel diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb index 654830ba11..8881c69368 100644 --- a/activerecord/test/models/developer.rb +++ b/activerecord/test/models/developer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "ostruct" module DeveloperProjectsAssociationExtension2 @@ -85,6 +87,17 @@ class Developer < ActiveRecord::Base private :track_instance_count end +class SubDeveloper < Developer +end + +class SymbolIgnoredDeveloper < ActiveRecord::Base + self.table_name = "developers" + self.ignored_columns = [:first_name, :last_name] + + attr_accessor :last_name + define_attribute_method "last_name" +end + class AuditLog < ActiveRecord::Base belongs_to :developer, validate: true belongs_to :unvalidated_developer, class_name: "Developer" diff --git a/activerecord/test/models/dog.rb b/activerecord/test/models/dog.rb index b02b8447b8..75d284ac25 100644 --- a/activerecord/test/models/dog.rb +++ b/activerecord/test/models/dog.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dog < ActiveRecord::Base belongs_to :breeder, class_name: "DogLover", counter_cache: :bred_dogs_count belongs_to :trainer, class_name: "DogLover", counter_cache: :trained_dogs_count diff --git a/activerecord/test/models/dog_lover.rb b/activerecord/test/models/dog_lover.rb index 2c5be94aea..aabe914f77 100644 --- a/activerecord/test/models/dog_lover.rb +++ b/activerecord/test/models/dog_lover.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DogLover < ActiveRecord::Base has_many :trained_dogs, class_name: "Dog", foreign_key: :trainer_id, dependent: :destroy has_many :bred_dogs, class_name: "Dog", foreign_key: :breeder_id diff --git a/activerecord/test/models/doubloon.rb b/activerecord/test/models/doubloon.rb index 7272504666..febadc3a5a 100644 --- a/activerecord/test/models/doubloon.rb +++ b/activerecord/test/models/doubloon.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AbstractDoubloon < ActiveRecord::Base # This has functionality that might be shared by multiple classes. diff --git a/activerecord/test/models/drink_designer.rb b/activerecord/test/models/drink_designer.rb index 2db968ef11..eb6701b84e 100644 --- a/activerecord/test/models/drink_designer.rb +++ b/activerecord/test/models/drink_designer.rb @@ -1,3 +1,8 @@ +# frozen_string_literal: true + class DrinkDesigner < ActiveRecord::Base has_one :chef, as: :employable end + +class MocktailDesigner < DrinkDesigner +end diff --git a/activerecord/test/models/edge.rb b/activerecord/test/models/edge.rb index e61d25c9bc..a04ab103de 100644 --- a/activerecord/test/models/edge.rb +++ b/activerecord/test/models/edge.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This class models an edge in a directed graph. class Edge < ActiveRecord::Base belongs_to :source, class_name: "Vertex", foreign_key: "source_id" diff --git a/activerecord/test/models/electron.rb b/activerecord/test/models/electron.rb index 6fc270673f..902006b314 100644 --- a/activerecord/test/models/electron.rb +++ b/activerecord/test/models/electron.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Electron < ActiveRecord::Base belongs_to :molecule diff --git a/activerecord/test/models/engine.rb b/activerecord/test/models/engine.rb index eada171f6a..396a52b3b9 100644 --- a/activerecord/test/models/engine.rb +++ b/activerecord/test/models/engine.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Engine < ActiveRecord::Base belongs_to :my_car, class_name: "Car", foreign_key: "car_id", counter_cache: :engines_count end diff --git a/activerecord/test/models/entrant.rb b/activerecord/test/models/entrant.rb index 4682ce48c8..2c086e451f 100644 --- a/activerecord/test/models/entrant.rb +++ b/activerecord/test/models/entrant.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Entrant < ActiveRecord::Base belongs_to :course end diff --git a/activerecord/test/models/essay.rb b/activerecord/test/models/essay.rb index 13267fbc21..e59db4d877 100644 --- a/activerecord/test/models/essay.rb +++ b/activerecord/test/models/essay.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class Essay < ActiveRecord::Base + belongs_to :author belongs_to :writer, primary_key: :name, polymorphic: true belongs_to :category, primary_key: :name has_one :owner, primary_key: :name diff --git a/activerecord/test/models/event.rb b/activerecord/test/models/event.rb index 365ab32b0b..a7cdc39e5c 100644 --- a/activerecord/test/models/event.rb +++ b/activerecord/test/models/event.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Event < ActiveRecord::Base validates_uniqueness_of :title end diff --git a/activerecord/test/models/eye.rb b/activerecord/test/models/eye.rb index f53c34e4b1..f3608b62ef 100644 --- a/activerecord/test/models/eye.rb +++ b/activerecord/test/models/eye.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Eye < ActiveRecord::Base attr_reader :after_create_callbacks_stack attr_reader :after_update_callbacks_stack diff --git a/activerecord/test/models/face.rb b/activerecord/test/models/face.rb index 5913bfa969..e900fd40fb 100644 --- a/activerecord/test/models/face.rb +++ b/activerecord/test/models/face.rb @@ -1,9 +1,16 @@ +# frozen_string_literal: true + class Face < ActiveRecord::Base belongs_to :man, inverse_of: :face + belongs_to :human, polymorphic: true belongs_to :polymorphic_man, polymorphic: true, inverse_of: :polymorphic_face # Oracle identifier length is limited to 30 bytes or less, `polymorphic` renamed `poly` belongs_to :poly_man_without_inverse, polymorphic: true # These is a "broken" inverse_of for the purposes of testing belongs_to :horrible_man, class_name: "Man", inverse_of: :horrible_face belongs_to :horrible_polymorphic_man, polymorphic: true, inverse_of: :horrible_polymorphic_face + + validate do + man + end end diff --git a/activerecord/test/models/family.rb b/activerecord/test/models/family.rb index 5ae5a78c95..0713dba1a6 100644 --- a/activerecord/test/models/family.rb +++ b/activerecord/test/models/family.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Family < ActiveRecord::Base has_many :family_trees, -> { where(token: nil) } has_many :members, through: :family_trees diff --git a/activerecord/test/models/family_tree.rb b/activerecord/test/models/family_tree.rb index cd9829fedd..a8ea907c05 100644 --- a/activerecord/test/models/family_tree.rb +++ b/activerecord/test/models/family_tree.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FamilyTree < ActiveRecord::Base belongs_to :member, class_name: "User", foreign_key: "member_id" belongs_to :family diff --git a/activerecord/test/models/friendship.rb b/activerecord/test/models/friendship.rb index 578382b494..9f1712a8ec 100644 --- a/activerecord/test/models/friendship.rb +++ b/activerecord/test/models/friendship.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Friendship < ActiveRecord::Base belongs_to :friend, class_name: "Person" # friend_too exists to test a bug, and probably shouldn't be used elsewhere diff --git a/activerecord/test/models/frog.rb b/activerecord/test/models/frog.rb new file mode 100644 index 0000000000..73601aacdd --- /dev/null +++ b/activerecord/test/models/frog.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Frog < ActiveRecord::Base + after_save do + with_lock do + end + end +end diff --git a/activerecord/test/models/guid.rb b/activerecord/test/models/guid.rb index 05653ba498..ec71c37690 100644 --- a/activerecord/test/models/guid.rb +++ b/activerecord/test/models/guid.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Guid < ActiveRecord::Base end diff --git a/activerecord/test/models/guitar.rb b/activerecord/test/models/guitar.rb index cd068ff53d..649b998665 100644 --- a/activerecord/test/models/guitar.rb +++ b/activerecord/test/models/guitar.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Guitar < ActiveRecord::Base has_many :tuning_pegs, index_errors: true accepts_nested_attributes_for :tuning_pegs diff --git a/activerecord/test/models/hotel.rb b/activerecord/test/models/hotel.rb index 7bc717c891..1a433c3cab 100644 --- a/activerecord/test/models/hotel.rb +++ b/activerecord/test/models/hotel.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Hotel < ActiveRecord::Base has_many :departments has_many :chefs, through: :departments diff --git a/activerecord/test/models/image.rb b/activerecord/test/models/image.rb index 7ae8e4a7f6..b4808293cc 100644 --- a/activerecord/test/models/image.rb +++ b/activerecord/test/models/image.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Image < ActiveRecord::Base belongs_to :imageable, foreign_key: :imageable_identifier, foreign_type: :imageable_class end diff --git a/activerecord/test/models/interest.rb b/activerecord/test/models/interest.rb index ec79416ee7..899b8f9b9d 100644 --- a/activerecord/test/models/interest.rb +++ b/activerecord/test/models/interest.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Interest < ActiveRecord::Base belongs_to :man, inverse_of: :interests belongs_to :polymorphic_man, polymorphic: true, inverse_of: :polymorphic_interests diff --git a/activerecord/test/models/invoice.rb b/activerecord/test/models/invoice.rb index 4be5a00193..1851792ed5 100644 --- a/activerecord/test/models/invoice.rb +++ b/activerecord/test/models/invoice.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Invoice < ActiveRecord::Base has_many :line_items, autosave: true before_save { |record| record.balance = record.line_items.map(&:amount).sum } diff --git a/activerecord/test/models/item.rb b/activerecord/test/models/item.rb index 336fb1769a..8d079d56e6 100644 --- a/activerecord/test/models/item.rb +++ b/activerecord/test/models/item.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AbstractItem < ActiveRecord::Base self.abstract_class = true has_one :tagging, as: :taggable diff --git a/activerecord/test/models/job.rb b/activerecord/test/models/job.rb index bbaef2792c..52817a8435 100644 --- a/activerecord/test/models/job.rb +++ b/activerecord/test/models/job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Job < ActiveRecord::Base has_many :references has_many :people, through: :references diff --git a/activerecord/test/models/joke.rb b/activerecord/test/models/joke.rb index eeb5818a1f..436ffb6471 100644 --- a/activerecord/test/models/joke.rb +++ b/activerecord/test/models/joke.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Joke < ActiveRecord::Base self.table_name = "funny_jokes" end diff --git a/activerecord/test/models/keyboard.rb b/activerecord/test/models/keyboard.rb index bcede53ec9..d200e0fb56 100644 --- a/activerecord/test/models/keyboard.rb +++ b/activerecord/test/models/keyboard.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Keyboard < ActiveRecord::Base self.primary_key = "key_number" end diff --git a/activerecord/test/models/legacy_thing.rb b/activerecord/test/models/legacy_thing.rb index eead181a0e..e0210c8922 100644 --- a/activerecord/test/models/legacy_thing.rb +++ b/activerecord/test/models/legacy_thing.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LegacyThing < ActiveRecord::Base self.locking_column = :version end diff --git a/activerecord/test/models/lesson.rb b/activerecord/test/models/lesson.rb index 4c88153068..e546339689 100644 --- a/activerecord/test/models/lesson.rb +++ b/activerecord/test/models/lesson.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LessonError < Exception end diff --git a/activerecord/test/models/line_item.rb b/activerecord/test/models/line_item.rb index 93f7cceb13..3a51cf03b2 100644 --- a/activerecord/test/models/line_item.rb +++ b/activerecord/test/models/line_item.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LineItem < ActiveRecord::Base belongs_to :invoice, touch: true end diff --git a/activerecord/test/models/liquid.rb b/activerecord/test/models/liquid.rb index 69d4d7df1a..b2fd305d66 100644 --- a/activerecord/test/models/liquid.rb +++ b/activerecord/test/models/liquid.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Liquid < ActiveRecord::Base self.table_name = :liquid has_many :molecules, -> { distinct } diff --git a/activerecord/test/models/man.rb b/activerecord/test/models/man.rb index d2436a735c..e26920e951 100644 --- a/activerecord/test/models/man.rb +++ b/activerecord/test/models/man.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Man < ActiveRecord::Base has_one :face, inverse_of: :man has_one :polymorphic_face, class_name: "Face", as: :polymorphic_man, inverse_of: :polymorphic_man @@ -9,3 +11,6 @@ class Man < ActiveRecord::Base has_many :secret_interests, class_name: "Interest", inverse_of: :secret_man has_one :mixed_case_monkey end + +class Human < Man +end diff --git a/activerecord/test/models/matey.rb b/activerecord/test/models/matey.rb index 80ee5f47c5..a77ac21e96 100644 --- a/activerecord/test/models/matey.rb +++ b/activerecord/test/models/matey.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Matey < ActiveRecord::Base belongs_to :pirate belongs_to :target, class_name: "Pirate" diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index 36f2461b84..6e33ac0a6d 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Member < ActiveRecord::Base has_one :current_membership has_one :selected_membership @@ -22,14 +24,16 @@ class Member < ActiveRecord::Base has_many :organization_member_details_2, through: :organization, source: :member_details has_one :club_category, through: :club, source: :category + has_one :general_club, -> { general }, through: :current_membership, source: :club - has_many :current_memberships, -> { where favourite: true } - has_many :clubs, through: :current_memberships + has_many :super_memberships + has_many :favourite_memberships, -> { where(favourite: true) }, class_name: "Membership" + has_many :clubs, through: :favourite_memberships has_many :tenant_memberships has_many :tenant_clubs, through: :tenant_memberships, class_name: "Club", source: :club - has_one :club_through_many, through: :current_memberships, source: :club + has_one :club_through_many, through: :favourite_memberships, source: :club belongs_to :admittable, polymorphic: true has_one :premium_club, through: :admittable diff --git a/activerecord/test/models/member_detail.rb b/activerecord/test/models/member_detail.rb index 157130986c..e121a849d0 100644 --- a/activerecord/test/models/member_detail.rb +++ b/activerecord/test/models/member_detail.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + class MemberDetail < ActiveRecord::Base belongs_to :member, inverse_of: false belongs_to :organization has_one :member_type, through: :member has_one :membership, through: :member + has_one :admittable, through: :member, source_type: "Member" has_many :organization_member_details, through: :organization, source: :member_details end diff --git a/activerecord/test/models/member_type.rb b/activerecord/test/models/member_type.rb index a13561c72a..b49b168d03 100644 --- a/activerecord/test/models/member_type.rb +++ b/activerecord/test/models/member_type.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MemberType < ActiveRecord::Base has_many :members end diff --git a/activerecord/test/models/membership.rb b/activerecord/test/models/membership.rb index 2c3ad230a7..09ee7544b3 100644 --- a/activerecord/test/models/membership.rb +++ b/activerecord/test/models/membership.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class Membership < ActiveRecord::Base + enum type: %i(Membership CurrentMembership SuperMembership SelectedMembership TenantMembership) belongs_to :member belongs_to :club end diff --git a/activerecord/test/models/mentor.rb b/activerecord/test/models/mentor.rb index 66504b4e91..2fbb62c435 100644 --- a/activerecord/test/models/mentor.rb +++ b/activerecord/test/models/mentor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Mentor < ActiveRecord::Base has_many :developers end diff --git a/activerecord/test/models/minimalistic.rb b/activerecord/test/models/minimalistic.rb index 2e3f8e081a..c67b086853 100644 --- a/activerecord/test/models/minimalistic.rb +++ b/activerecord/test/models/minimalistic.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Minimalistic < ActiveRecord::Base end diff --git a/activerecord/test/models/minivan.rb b/activerecord/test/models/minivan.rb index e9b05dadf2..d9d331798a 100644 --- a/activerecord/test/models/minivan.rb +++ b/activerecord/test/models/minivan.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Minivan < ActiveRecord::Base self.primary_key = :minivan_id diff --git a/activerecord/test/models/mixed_case_monkey.rb b/activerecord/test/models/mixed_case_monkey.rb index 1c35006665..8e92f68817 100644 --- a/activerecord/test/models/mixed_case_monkey.rb +++ b/activerecord/test/models/mixed_case_monkey.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MixedCaseMonkey < ActiveRecord::Base belongs_to :man end diff --git a/activerecord/test/models/mocktail_designer.rb b/activerecord/test/models/mocktail_designer.rb deleted file mode 100644 index 77b44651a3..0000000000 --- a/activerecord/test/models/mocktail_designer.rb +++ /dev/null @@ -1,2 +0,0 @@ -class MocktailDesigner < DrinkDesigner -end diff --git a/activerecord/test/models/molecule.rb b/activerecord/test/models/molecule.rb index 26870c8f88..7da08a85c4 100644 --- a/activerecord/test/models/molecule.rb +++ b/activerecord/test/models/molecule.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Molecule < ActiveRecord::Base belongs_to :liquid has_many :electrons diff --git a/activerecord/test/models/movie.rb b/activerecord/test/models/movie.rb index 0302abad1e..fa2ea900c7 100644 --- a/activerecord/test/models/movie.rb +++ b/activerecord/test/models/movie.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Movie < ActiveRecord::Base self.primary_key = "movieid" diff --git a/activerecord/test/models/node.rb b/activerecord/test/models/node.rb index 459ea8cf95..ae46c76b46 100644 --- a/activerecord/test/models/node.rb +++ b/activerecord/test/models/node.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Node < ActiveRecord::Base belongs_to :tree, touch: true belongs_to :parent, class_name: "Node", touch: true, optional: true diff --git a/activerecord/test/models/non_primary_key.rb b/activerecord/test/models/non_primary_key.rb index 1cafb09608..e954375989 100644 --- a/activerecord/test/models/non_primary_key.rb +++ b/activerecord/test/models/non_primary_key.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class NonPrimaryKey < ActiveRecord::Base end diff --git a/activerecord/test/models/notification.rb b/activerecord/test/models/notification.rb index 82edc64b68..3f8728af5e 100644 --- a/activerecord/test/models/notification.rb +++ b/activerecord/test/models/notification.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Notification < ActiveRecord::Base validates_presence_of :message end diff --git a/activerecord/test/models/numeric_data.rb b/activerecord/test/models/numeric_data.rb new file mode 100644 index 0000000000..666e1a5778 --- /dev/null +++ b/activerecord/test/models/numeric_data.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class NumericData < ActiveRecord::Base + self.table_name = "numeric_data" + # Decimal columns with 0 scale being automatically treated as integers + # is deprecated, and will be removed in a future version of Rails. + attribute :world_population, :big_integer + attribute :my_house_population, :big_integer + attribute :atoms_in_universe, :big_integer +end diff --git a/activerecord/test/models/order.rb b/activerecord/test/models/order.rb index 699be53959..36866b398f 100644 --- a/activerecord/test/models/order.rb +++ b/activerecord/test/models/order.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Order < ActiveRecord::Base belongs_to :billing, class_name: "Customer", foreign_key: "billing_customer_id" belongs_to :shipping, class_name: "Customer", foreign_key: "shipping_customer_id" diff --git a/activerecord/test/models/organization.rb b/activerecord/test/models/organization.rb index 462830dadc..099e7e38e0 100644 --- a/activerecord/test/models/organization.rb +++ b/activerecord/test/models/organization.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Organization < ActiveRecord::Base has_many :member_details has_many :members, through: :member_details diff --git a/activerecord/test/models/other_dog.rb b/activerecord/test/models/other_dog.rb index 418caf34be..a0fda5ae1b 100644 --- a/activerecord/test/models/other_dog.rb +++ b/activerecord/test/models/other_dog.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_dependency "models/arunit2_model" class OtherDog < ARUnit2Model diff --git a/activerecord/test/models/owner.rb b/activerecord/test/models/owner.rb index 21fc9b6eb8..5fa50d9918 100644 --- a/activerecord/test/models/owner.rb +++ b/activerecord/test/models/owner.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Owner < ActiveRecord::Base self.primary_key = :owner_id has_many :pets, -> { order "pets.name desc" } diff --git a/activerecord/test/models/parrot.rb b/activerecord/test/models/parrot.rb index 1e5f9285a8..ba9ddb8c6a 100644 --- a/activerecord/test/models/parrot.rb +++ b/activerecord/test/models/parrot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Parrot < ActiveRecord::Base self.inheritance_column = :parrot_sti_class @@ -8,7 +10,7 @@ class Parrot < ActiveRecord::Base validates_presence_of :name - attr_accessor :cancel_save_from_callback + attribute :cancel_save_from_callback before_save :cancel_save_callback_method, if: :cancel_save_from_callback def cancel_save_callback_method throw(:abort) diff --git a/activerecord/test/models/person.rb b/activerecord/test/models/person.rb index 18994d6f18..5cba1e440e 100644 --- a/activerecord/test/models/person.rb +++ b/activerecord/test/models/person.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Person < ActiveRecord::Base has_many :readers has_many :secure_readers diff --git a/activerecord/test/models/personal_legacy_thing.rb b/activerecord/test/models/personal_legacy_thing.rb index adde7a504a..ed8b70cfcc 100644 --- a/activerecord/test/models/personal_legacy_thing.rb +++ b/activerecord/test/models/personal_legacy_thing.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PersonalLegacyThing < ActiveRecord::Base self.locking_column = :version belongs_to :person, counter_cache: true diff --git a/activerecord/test/models/pet.rb b/activerecord/test/models/pet.rb index 51a3e42815..9bda2109e6 100644 --- a/activerecord/test/models/pet.rb +++ b/activerecord/test/models/pet.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Pet < ActiveRecord::Base attr_accessor :current_user diff --git a/activerecord/test/models/pet_treasure.rb b/activerecord/test/models/pet_treasure.rb index 1fe7807ffe..47b9f57fad 100644 --- a/activerecord/test/models/pet_treasure.rb +++ b/activerecord/test/models/pet_treasure.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PetTreasure < ActiveRecord::Base self.table_name = "pets_treasures" diff --git a/activerecord/test/models/pirate.rb b/activerecord/test/models/pirate.rb index c532ab426e..fd5083e597 100644 --- a/activerecord/test/models/pirate.rb +++ b/activerecord/test/models/pirate.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Pirate < ActiveRecord::Base belongs_to :parrot, validate: true belongs_to :non_validated_parrot, class_name: "Parrot" @@ -15,7 +17,13 @@ class Pirate < ActiveRecord::Base after_remove: proc { |p, pa| p.ship_log << "after_removing_proc_parrot_#{pa.id}" } has_and_belongs_to_many :autosaved_parrots, class_name: "Parrot", autosave: true - has_many :treasures, as: :looter + module PostTreasuresExtension + def build(attributes = {}) + super({ name: "from extension" }.merge(attributes)) + end + end + + has_many :treasures, as: :looter, extend: PostTreasuresExtension has_many :treasure_estimates, through: :treasures, source: :price_estimates has_one :ship diff --git a/activerecord/test/models/possession.rb b/activerecord/test/models/possession.rb index 0226336c16..9b843e1525 100644 --- a/activerecord/test/models/possession.rb +++ b/activerecord/test/models/possession.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Possession < ActiveRecord::Base self.table_name = "having" end diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index e74aedb814..528585fb75 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Post < ActiveRecord::Base class CategoryPost < ActiveRecord::Base self.table_name = "categories_posts" @@ -13,15 +15,16 @@ class Post < ActiveRecord::Base module NamedExtension2 def greeting - "hello" + "hullo" end end scope :containing_the_letter_a, -> { where("body LIKE '%a%'") } scope :titled_with_an_apostrophe, -> { where("title LIKE '%''%'") } - scope :ranked_by_comments, -> { order("comments_count DESC") } + scope :ranked_by_comments, -> { order(arel_attribute(:comments_count).desc) } scope :limit_by, lambda { |l| limit(l) } + scope :locked, -> { lock } belongs_to :author belongs_to :readonly_author, -> { readonly }, class_name: "Author", foreign_key: :author_id @@ -59,6 +62,10 @@ class Post < ActiveRecord::Base def the_association proxy_association end + + def with_content(content) + self.detect { |comment| comment.body == content } + end end has_many :comments_with_extend, extend: NamedExtension, class_name: "Comment", foreign_key: "post_id" do @@ -99,6 +106,9 @@ class Post < ActiveRecord::Base end end + has_many :indestructible_taggings, as: :taggable, counter_cache: :indestructible_tags_count + has_many :indestructible_tags, through: :indestructible_taggings, source: :tag + has_many :taggings_with_delete_all, class_name: "Tagging", as: :taggable, dependent: :delete_all, counter_cache: :taggings_with_delete_all_count has_many :taggings_with_destroy, class_name: "Tagging", as: :taggable, dependent: :destroy, counter_cache: :taggings_with_destroy_count @@ -108,6 +118,7 @@ class Post < ActiveRecord::Base has_many :misc_tags, -> { where tags: { name: "Misc" } }, through: :taggings, source: :tag has_many :funky_tags, through: :taggings, source: :tag has_many :super_tags, through: :taggings + has_many :ordered_tags, through: :taggings has_many :tags_with_primary_key, through: :taggings, source: :tag_with_primary_key has_one :tagging, as: :taggable @@ -177,14 +188,19 @@ end class SpecialPost < Post; end class StiPost < Post - self.abstract_class = true has_one :special_comment, class_name: "SpecialComment" end +class AbstractStiPost < Post + self.abstract_class = true +end + class SubStiPost < StiPost self.table_name = Post.table_name end +class SubAbstractStiPost < AbstractStiPost; end + class FirstPost < ActiveRecord::Base self.inheritance_column = :disabled self.table_name = "posts" @@ -194,6 +210,11 @@ class FirstPost < ActiveRecord::Base has_one :comment, foreign_key: :post_id end +class TaggedPost < Post + has_many :taggings, -> { rewhere(taggable_type: "TaggedPost") }, as: :taggable + has_many :tags, through: :taggings +end + class PostWithDefaultInclude < ActiveRecord::Base self.inheritance_column = :disabled self.table_name = "posts" @@ -232,6 +253,7 @@ class SpecialPostWithDefaultScope < ActiveRecord::Base self.inheritance_column = :disabled self.table_name = "posts" default_scope { where(id: [1, 5, 6]) } + scope :unscoped_all, -> { unscoped { all } } end class PostThatLoadsCommentsInAnAfterSaveHook < ActiveRecord::Base @@ -271,3 +293,47 @@ end class SubConditionalStiPost < ConditionalStiPost end + +class FakeKlass + extend ActiveRecord::Delegation::DelegateCache + + inherited self + + class << self + def connection + Post.connection + end + + def table_name + "posts" + end + + def attribute_alias?(name) + false + end + + def sanitize_sql(sql) + sql + end + + def sanitize_sql_for_order(sql) + sql + end + + def arel_attribute(name, table) + table[name] + end + + def disallow_raw_sql!(*args) + # noop + end + + def arel_table + Post.arel_table + end + + def predicate_builder + Post.predicate_builder + end + end +end diff --git a/activerecord/test/models/price_estimate.rb b/activerecord/test/models/price_estimate.rb index ce086e40a3..669d0991f7 100644 --- a/activerecord/test/models/price_estimate.rb +++ b/activerecord/test/models/price_estimate.rb @@ -1,4 +1,14 @@ +# frozen_string_literal: true + class PriceEstimate < ActiveRecord::Base + include ActiveSupport::NumberHelper + belongs_to :estimate_of, polymorphic: true belongs_to :thing, polymorphic: true + + validates_numericality_of :price + + def price + number_to_currency super + end end diff --git a/activerecord/test/models/professor.rb b/activerecord/test/models/professor.rb index 4dfb1a9602..abc23f40ff 100644 --- a/activerecord/test/models/professor.rb +++ b/activerecord/test/models/professor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_dependency "models/arunit2_model" class Professor < ARUnit2Model diff --git a/activerecord/test/models/project.rb b/activerecord/test/models/project.rb index 4fbd986e40..846cef625b 100644 --- a/activerecord/test/models/project.rb +++ b/activerecord/test/models/project.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Project < ActiveRecord::Base belongs_to :mentor has_and_belongs_to_many :developers, -> { distinct.order "developers.name desc, developers.id desc" } diff --git a/activerecord/test/models/publisher.rb b/activerecord/test/models/publisher.rb index 0d4a7f9235..53677197c4 100644 --- a/activerecord/test/models/publisher.rb +++ b/activerecord/test/models/publisher.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + module Publisher end diff --git a/activerecord/test/models/publisher/article.rb b/activerecord/test/models/publisher/article.rb index d73a8eb936..355c22dcc5 100644 --- a/activerecord/test/models/publisher/article.rb +++ b/activerecord/test/models/publisher/article.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Publisher::Article < ActiveRecord::Base has_and_belongs_to_many :magazines has_and_belongs_to_many :tags diff --git a/activerecord/test/models/publisher/magazine.rb b/activerecord/test/models/publisher/magazine.rb index 82e1a14008..425ede8df2 100644 --- a/activerecord/test/models/publisher/magazine.rb +++ b/activerecord/test/models/publisher/magazine.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Publisher::Magazine < ActiveRecord::Base has_and_belongs_to_many :articles end diff --git a/activerecord/test/models/randomly_named_c1.rb b/activerecord/test/models/randomly_named_c1.rb index d4be1e13b4..f90a7b9336 100644 --- a/activerecord/test/models/randomly_named_c1.rb +++ b/activerecord/test/models/randomly_named_c1.rb @@ -1,3 +1,5 @@ -class ClassNameThatDoesNotFollowCONVENTIONS < ActiveRecord::Base
- self.table_name = :randomly_named_table1
-end
+# frozen_string_literal: true + +class ClassNameThatDoesNotFollowCONVENTIONS < ActiveRecord::Base + self.table_name = :randomly_named_table1 +end diff --git a/activerecord/test/models/rating.rb b/activerecord/test/models/rating.rb index 7420821db0..cf06bc6931 100644 --- a/activerecord/test/models/rating.rb +++ b/activerecord/test/models/rating.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Rating < ActiveRecord::Base belongs_to :comment has_many :taggings, as: :taggable diff --git a/activerecord/test/models/reader.rb b/activerecord/test/models/reader.rb index 7c5a159fe0..d25627e430 100644 --- a/activerecord/test/models/reader.rb +++ b/activerecord/test/models/reader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Reader < ActiveRecord::Base belongs_to :post belongs_to :person, inverse_of: :readers diff --git a/activerecord/test/models/recipe.rb b/activerecord/test/models/recipe.rb index c387230603..e53f5c8fb1 100644 --- a/activerecord/test/models/recipe.rb +++ b/activerecord/test/models/recipe.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Recipe < ActiveRecord::Base belongs_to :chef end diff --git a/activerecord/test/models/record.rb b/activerecord/test/models/record.rb index f77ac9fc03..63143e296a 100644 --- a/activerecord/test/models/record.rb +++ b/activerecord/test/models/record.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Record < ActiveRecord::Base end diff --git a/activerecord/test/models/reference.rb b/activerecord/test/models/reference.rb index e2bb980fed..2a7a1e3b77 100644 --- a/activerecord/test/models/reference.rb +++ b/activerecord/test/models/reference.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Reference < ActiveRecord::Base belongs_to :person belongs_to :job diff --git a/activerecord/test/models/reply.rb b/activerecord/test/models/reply.rb index a2d169292a..0ea110f4f8 100644 --- a/activerecord/test/models/reply.rb +++ b/activerecord/test/models/reply.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + require "models/topic" class Reply < Topic belongs_to :topic, foreign_key: "parent_id", counter_cache: true - belongs_to :topic_with_primary_key, class_name: "Topic", primary_key: "title", foreign_key: "parent_title", counter_cache: "replies_count" + belongs_to :topic_with_primary_key, class_name: "Topic", primary_key: "title", foreign_key: "parent_title", counter_cache: "replies_count", touch: true has_many :replies, class_name: "SillyReply", dependent: :destroy, foreign_key: "parent_id" + has_many :silly_unique_replies, dependent: :destroy, foreign_key: "parent_id" end class UniqueReply < Reply @@ -12,6 +15,7 @@ class UniqueReply < Reply end class SillyUniqueReply < UniqueReply + validates :content, uniqueness: true end class WrongReply < Reply diff --git a/activerecord/test/models/ship.rb b/activerecord/test/models/ship.rb index 77a7b22315..7973219a79 100644 --- a/activerecord/test/models/ship.rb +++ b/activerecord/test/models/ship.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Ship < ActiveRecord::Base self.record_timestamps = false diff --git a/activerecord/test/models/ship_part.rb b/activerecord/test/models/ship_part.rb index 1a633b8d77..f6d7a8ae5e 100644 --- a/activerecord/test/models/ship_part.rb +++ b/activerecord/test/models/ship_part.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ShipPart < ActiveRecord::Base belongs_to :ship has_many :trinkets, class_name: "Treasure", as: :looter diff --git a/activerecord/test/models/shop.rb b/activerecord/test/models/shop.rb index f9d23d13b0..92afe70b92 100644 --- a/activerecord/test/models/shop.rb +++ b/activerecord/test/models/shop.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Shop class Collection < ActiveRecord::Base has_many :products, dependent: :nullify diff --git a/activerecord/test/models/shop_account.rb b/activerecord/test/models/shop_account.rb index 1580e8b20c..97fb058331 100644 --- a/activerecord/test/models/shop_account.rb +++ b/activerecord/test/models/shop_account.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ShopAccount < ActiveRecord::Base belongs_to :customer belongs_to :customer_carrier diff --git a/activerecord/test/models/speedometer.rb b/activerecord/test/models/speedometer.rb index 497c3aba9a..e456907a22 100644 --- a/activerecord/test/models/speedometer.rb +++ b/activerecord/test/models/speedometer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Speedometer < ActiveRecord::Base self.primary_key = :speedometer_id belongs_to :dashboard diff --git a/activerecord/test/models/sponsor.rb b/activerecord/test/models/sponsor.rb index 3f142b25fe..18ff103ffe 100644 --- a/activerecord/test/models/sponsor.rb +++ b/activerecord/test/models/sponsor.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + class Sponsor < ActiveRecord::Base belongs_to :sponsor_club, class_name: "Club", foreign_key: "club_id" belongs_to :sponsorable, polymorphic: true + belongs_to :sponsor, polymorphic: true belongs_to :thing, polymorphic: true, foreign_type: :sponsorable_type, foreign_key: :sponsorable_id belongs_to :sponsorable_with_conditions, -> { where name: "Ernie" }, polymorphic: true, foreign_type: "sponsorable_type", foreign_key: "sponsorable_id" diff --git a/activerecord/test/models/string_key_object.rb b/activerecord/test/models/string_key_object.rb index f084ec1bdc..473c145f4c 100644 --- a/activerecord/test/models/string_key_object.rb +++ b/activerecord/test/models/string_key_object.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StringKeyObject < ActiveRecord::Base self.primary_key = :id end diff --git a/activerecord/test/models/student.rb b/activerecord/test/models/student.rb index 28a0b6c99b..e750798f74 100644 --- a/activerecord/test/models/student.rb +++ b/activerecord/test/models/student.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Student < ActiveRecord::Base has_and_belongs_to_many :lessons belongs_to :college diff --git a/activerecord/test/models/subject.rb b/activerecord/test/models/subject.rb deleted file mode 100644 index 504f68a296..0000000000 --- a/activerecord/test/models/subject.rb +++ /dev/null @@ -1,14 +0,0 @@ -# used for OracleSynonymTest, see test/synonym_test_oracle.rb -# -class Subject < ActiveRecord::Base - # added initialization of author_email_address in the same way as in Topic class - # as otherwise synonym test was failing - after_initialize :set_email_address - - private - def set_email_address - unless persisted? - self.author_email_address = "test@test.com" - end - end -end diff --git a/activerecord/test/models/subscriber.rb b/activerecord/test/models/subscriber.rb index a820329003..b21969ca2d 100644 --- a/activerecord/test/models/subscriber.rb +++ b/activerecord/test/models/subscriber.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Subscriber < ActiveRecord::Base self.primary_key = "nick" has_many :subscriptions diff --git a/activerecord/test/models/subscription.rb b/activerecord/test/models/subscription.rb index 1cedf6deae..d1d5d21621 100644 --- a/activerecord/test/models/subscription.rb +++ b/activerecord/test/models/subscription.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Subscription < ActiveRecord::Base belongs_to :subscriber, counter_cache: :books_count belongs_to :book diff --git a/activerecord/test/models/tag.rb b/activerecord/test/models/tag.rb index c907aea10f..c1a8890a8a 100644 --- a/activerecord/test/models/tag.rb +++ b/activerecord/test/models/tag.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Tag < ActiveRecord::Base has_many :taggings has_many :taggables, through: :taggings @@ -9,5 +11,6 @@ end class OrderedTag < Tag self.table_name = "tags" - has_many :taggings, -> { order("taggings.id DESC") }, foreign_key: "tag_id" + has_many :ordered_taggings, -> { order("taggings.id DESC") }, foreign_key: "tag_id", class_name: "Tagging" + has_many :tagged_posts, through: :ordered_taggings, source: "taggable", source_type: "Post" end diff --git a/activerecord/test/models/tagging.rb b/activerecord/test/models/tagging.rb index f739b4a197..6d4230f6f4 100644 --- a/activerecord/test/models/tagging.rb +++ b/activerecord/test/models/tagging.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # test that attr_readonly isn't called on the :taggable polymorphic association module Taggable end @@ -6,8 +8,13 @@ class Tagging < ActiveRecord::Base belongs_to :tag, -> { includes(:tagging) } belongs_to :super_tag, class_name: "Tag", foreign_key: "super_tag_id" belongs_to :invalid_tag, class_name: "Tag", foreign_key: "tag_id" + belongs_to :ordered_tag, class_name: "OrderedTag", foreign_key: "tag_id" belongs_to :blue_tag, -> { where tags: { name: "Blue" } }, class_name: "Tag", foreign_key: :tag_id belongs_to :tag_with_primary_key, class_name: "Tag", foreign_key: :tag_id, primary_key: :custom_primary_key belongs_to :taggable, polymorphic: true, counter_cache: :tags_count has_many :things, through: :taggable end + +class IndestructibleTagging < Tagging + before_destroy { throw :abort } +end diff --git a/activerecord/test/models/task.rb b/activerecord/test/models/task.rb index e36989dd56..dabe3ce06b 100644 --- a/activerecord/test/models/task.rb +++ b/activerecord/test/models/task.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Task < ActiveRecord::Base def updated_at ending diff --git a/activerecord/test/models/topic.rb b/activerecord/test/models/topic.rb index 0420e2d15c..72699046f9 100644 --- a/activerecord/test/models/topic.rb +++ b/activerecord/test/models/topic.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Topic < ActiveRecord::Base scope :base, -> { all } scope :written_before, lambda { |time| @@ -10,11 +12,19 @@ class Topic < ActiveRecord::Base scope :scope_with_lambda, lambda { all } + scope :by_private_lifo, -> { where(author_name: private_lifo) } scope :by_lifo, -> { where(author_name: "lifo") } scope :replied, -> { where "replies_count > 0" } + class << self + private + def private_lifo + "lifo" + end + end + scope "approved_as_string", -> { where(approved: true) } - scope :anonymous_extension, -> { all } do + scope :anonymous_extension, -> {} do def one 1 end @@ -63,11 +73,24 @@ class Topic < ActiveRecord::Base after_initialize :set_email_address + attr_accessor :change_approved_before_save + before_save :change_approved_callback + class_attribute :after_initialize_called after_initialize do self.class.after_initialize_called = true end + attr_accessor :after_touch_called + + after_initialize do + self.after_touch_called = 0 + end + + after_touch do + self.after_touch_called += 1 + end + def approved=(val) @custom_approved = val write_attribute(:approved, val) @@ -84,7 +107,7 @@ class Topic < ActiveRecord::Base end def set_email_address - unless persisted? + unless persisted? || will_save_change_to_author_email_address? self.author_email_address = "test@test.com" end end @@ -94,6 +117,10 @@ class Topic < ActiveRecord::Base def before_destroy_for_transaction; end def after_save_for_transaction; end def after_create_for_transaction; end + + def change_approved_callback + self.approved = change_approved_before_save unless change_approved_before_save.nil? + end end class ImportantTopic < Topic diff --git a/activerecord/test/models/toy.rb b/activerecord/test/models/toy.rb index ddc7048a56..4a5697eeb1 100644 --- a/activerecord/test/models/toy.rb +++ b/activerecord/test/models/toy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Toy < ActiveRecord::Base self.primary_key = :toy_id belongs_to :pet diff --git a/activerecord/test/models/traffic_light.rb b/activerecord/test/models/traffic_light.rb index a6b7edb882..0b88815cbd 100644 --- a/activerecord/test/models/traffic_light.rb +++ b/activerecord/test/models/traffic_light.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TrafficLight < ActiveRecord::Base serialize :state, Array serialize :long_state, Array diff --git a/activerecord/test/models/treasure.rb b/activerecord/test/models/treasure.rb index fb2a5d44e2..b51db56c37 100644 --- a/activerecord/test/models/treasure.rb +++ b/activerecord/test/models/treasure.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Treasure < ActiveRecord::Base has_and_belongs_to_many :parrots belongs_to :looter, polymorphic: true diff --git a/activerecord/test/models/treaty.rb b/activerecord/test/models/treaty.rb index 373cd48f71..5c1d75aa09 100644 --- a/activerecord/test/models/treaty.rb +++ b/activerecord/test/models/treaty.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Treaty < ActiveRecord::Base self.primary_key = :treaty_id diff --git a/activerecord/test/models/tree.rb b/activerecord/test/models/tree.rb index dc29cccc9c..77050c5fff 100644 --- a/activerecord/test/models/tree.rb +++ b/activerecord/test/models/tree.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Tree < ActiveRecord::Base has_many :nodes, dependent: :destroy end diff --git a/activerecord/test/models/tuning_peg.rb b/activerecord/test/models/tuning_peg.rb index 1252d6dc1d..6e052e1d0f 100644 --- a/activerecord/test/models/tuning_peg.rb +++ b/activerecord/test/models/tuning_peg.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TuningPeg < ActiveRecord::Base belongs_to :guitar validates_numericality_of :pitch diff --git a/activerecord/test/models/tyre.rb b/activerecord/test/models/tyre.rb index e50a21ca68..d627026585 100644 --- a/activerecord/test/models/tyre.rb +++ b/activerecord/test/models/tyre.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Tyre < ActiveRecord::Base belongs_to :car diff --git a/activerecord/test/models/user.rb b/activerecord/test/models/user.rb index 5089a795f4..3efbc45d2a 100644 --- a/activerecord/test/models/user.rb +++ b/activerecord/test/models/user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "models/job" class User < ActiveRecord::Base diff --git a/activerecord/test/models/uuid_child.rb b/activerecord/test/models/uuid_child.rb index a3d0962ad6..9fce361cc8 100644 --- a/activerecord/test/models/uuid_child.rb +++ b/activerecord/test/models/uuid_child.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UuidChild < ActiveRecord::Base belongs_to :uuid_parent end diff --git a/activerecord/test/models/uuid_item.rb b/activerecord/test/models/uuid_item.rb index 2353e40213..41f68c4c18 100644 --- a/activerecord/test/models/uuid_item.rb +++ b/activerecord/test/models/uuid_item.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UuidItem < ActiveRecord::Base end diff --git a/activerecord/test/models/uuid_parent.rb b/activerecord/test/models/uuid_parent.rb index 5634f22d0c..05db61855e 100644 --- a/activerecord/test/models/uuid_parent.rb +++ b/activerecord/test/models/uuid_parent.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UuidParent < ActiveRecord::Base has_many :uuid_children end diff --git a/activerecord/test/models/vegetables.rb b/activerecord/test/models/vegetables.rb index a4590d06e0..cfaab08ed1 100644 --- a/activerecord/test/models/vegetables.rb +++ b/activerecord/test/models/vegetables.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Vegetable < ActiveRecord::Base validates_presence_of :name diff --git a/activerecord/test/models/vehicle.rb b/activerecord/test/models/vehicle.rb index 855bc4e325..c9b3338522 100644 --- a/activerecord/test/models/vehicle.rb +++ b/activerecord/test/models/vehicle.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Vehicle < ActiveRecord::Base self.abstract_class = true default_scope -> { where("tires_count IS NOT NULL") } diff --git a/activerecord/test/models/vertex.rb b/activerecord/test/models/vertex.rb index 3d19433b6f..0ad8114898 100644 --- a/activerecord/test/models/vertex.rb +++ b/activerecord/test/models/vertex.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This class models a vertex in a directed graph. class Vertex < ActiveRecord::Base has_many :sink_edges, class_name: "Edge", foreign_key: "source_id" diff --git a/activerecord/test/models/warehouse_thing.rb b/activerecord/test/models/warehouse_thing.rb index f20bd1a245..099633af55 100644 --- a/activerecord/test/models/warehouse_thing.rb +++ b/activerecord/test/models/warehouse_thing.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WarehouseThing < ActiveRecord::Base self.table_name = "warehouse-things" diff --git a/activerecord/test/models/wheel.rb b/activerecord/test/models/wheel.rb index cba2b3e518..22fc74995f 100644 --- a/activerecord/test/models/wheel.rb +++ b/activerecord/test/models/wheel.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Wheel < ActiveRecord::Base - belongs_to :wheelable, polymorphic: true, counter_cache: true + belongs_to :wheelable, polymorphic: true, counter_cache: true, touch: :wheels_owned_at end diff --git a/activerecord/test/models/without_table.rb b/activerecord/test/models/without_table.rb index 7c0fc286e1..fc0a52d6ad 100644 --- a/activerecord/test/models/without_table.rb +++ b/activerecord/test/models/without_table.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WithoutTable < ActiveRecord::Base default_scope -> { where(published: true) } end diff --git a/activerecord/test/models/zine.rb b/activerecord/test/models/zine.rb index 3f2b348b46..6f361665ef 100644 --- a/activerecord/test/models/zine.rb +++ b/activerecord/test/models/zine.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Zine < ActiveRecord::Base has_many :interests, inverse_of: :zine end diff --git a/activerecord/test/schema/mysql2_specific_schema.rb b/activerecord/test/schema/mysql2_specific_schema.rb index 90a314c83c..c384297658 100644 --- a/activerecord/test/schema/mysql2_specific_schema.rb +++ b/activerecord/test/schema/mysql2_specific_schema.rb @@ -1,14 +1,24 @@ -ActiveRecord::Schema.define do +# frozen_string_literal: true - if ActiveRecord::Base.connection.version >= "5.6.0" +ActiveRecord::Schema.define do + if subsecond_precision_supported? create_table :datetime_defaults, force: true do |t| t.datetime :modified_datetime, default: -> { "CURRENT_TIMESTAMP" } + t.datetime :precise_datetime, precision: 6, default: -> { "CURRENT_TIMESTAMP(6)" } + end + + create_table :timestamp_defaults, force: true do |t| + t.timestamp :nullable_timestamp + t.timestamp :modified_timestamp, default: -> { "CURRENT_TIMESTAMP" } + t.timestamp :precise_timestamp, precision: 6, default: -> { "CURRENT_TIMESTAMP(6)" } end end - create_table :timestamp_defaults, force: true do |t| - t.timestamp :nullable_timestamp - t.timestamp :modified_timestamp, default: -> { "CURRENT_TIMESTAMP" } + create_table :defaults, force: true do |t| + t.date :fixed_date, default: "2004-01-01" + t.datetime :fixed_time, default: "2004-01-01 00:00:00" + t.column :char1, "char(1)", default: "Y" + t.string :char2, limit: 50, default: "a varchar field" end create_table :binary_fields, force: true do |t| @@ -26,7 +36,7 @@ ActiveRecord::Schema.define do t.index :var_binary end - create_table :key_tests, force: true, options: "ENGINE=MyISAM" do |t| + create_table :key_tests, force: true do |t| t.string :awesome t.string :pizza t.string :snacks diff --git a/activerecord/test/schema/oracle_specific_schema.rb b/activerecord/test/schema/oracle_specific_schema.rb index 264d9b8910..bc1e45ca80 100644 --- a/activerecord/test/schema/oracle_specific_schema.rb +++ b/activerecord/test/schema/oracle_specific_schema.rb @@ -1,5 +1,6 @@ -ActiveRecord::Schema.define do +# frozen_string_literal: true +ActiveRecord::Schema.define do execute "drop table test_oracle_defaults" rescue nil execute "drop sequence test_oracle_defaults_seq" rescue nil execute "drop sequence companies_nonstd_seq" rescue nil @@ -36,5 +37,4 @@ create sequence test_oracle_defaults_seq minvalue 10000 ) SQL execute "create sequence defaults_seq minvalue 10000" - end diff --git a/activerecord/test/schema/postgresql_specific_schema.rb b/activerecord/test/schema/postgresql_specific_schema.rb index 860c63b27c..975824ed51 100644 --- a/activerecord/test/schema/postgresql_specific_schema.rb +++ b/activerecord/test/schema/postgresql_specific_schema.rb @@ -1,13 +1,16 @@ -ActiveRecord::Schema.define do +# frozen_string_literal: true +ActiveRecord::Schema.define do enable_extension!("uuid-ossp", ActiveRecord::Base.connection) enable_extension!("pgcrypto", ActiveRecord::Base.connection) if ActiveRecord::Base.connection.supports_pgcrypto_uuid? - create_table :uuid_parents, id: :uuid, force: true do |t| + uuid_default = connection.supports_pgcrypto_uuid? ? {} : { default: "uuid_generate_v4()" } + + create_table :uuid_parents, id: :uuid, force: true, **uuid_default do |t| t.string :name end - create_table :uuid_children, id: :uuid, force: true do |t| + create_table :uuid_children, id: :uuid, force: true, **uuid_default do |t| t.string :name t.uuid :uuid_parent_id end @@ -102,7 +105,7 @@ _SQL end create_table :uuid_items, force: true, id: false do |t| - t.uuid :uuid, primary_key: true + t.uuid :uuid, primary_key: true, **uuid_default t.string :title end end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 08bef08abc..266e55f682 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ActiveRecord::Schema.define do # ------------------------------------------------------------------- # # # @@ -7,7 +9,7 @@ ActiveRecord::Schema.define do # ------------------------------------------------------------------- # create_table :accounts, force: true do |t| - t.integer :firm_id + t.references :firm, index: false t.string :firm_name t.integer :credit_limit end @@ -19,6 +21,9 @@ ActiveRecord::Schema.define do create_table :admin_users, force: true do |t| t.string :name t.string :settings, null: true, limit: 1024 + t.string :parent, null: true, limit: 1024 + t.string :spouse, null: true, limit: 1024 + t.string :configs, null: true, limit: 1024 # MySQL does not allow default values for blobs. Fake it out with a # big varchar below. t.string :preferences, null: true, default: "", limit: 1024 @@ -31,6 +36,7 @@ ActiveRecord::Schema.define do create_table :aircraft, force: true do |t| t.string :name t.integer :wheels_count, default: 0, null: false + t.datetime :wheels_owned_at end create_table :articles, force: true do |t| @@ -107,7 +113,7 @@ ActiveRecord::Schema.define do t.boolean :has_fun, null: false, default: false end - create_table :bulbs, force: true do |t| + create_table :bulbs, primary_key: "ID", force: true do |t| t.integer :car_id t.string :name t.boolean :frickinawesome, default: false @@ -121,7 +127,8 @@ ActiveRecord::Schema.define do create_table :cars, force: true do |t| t.string :name t.integer :engines_count - t.integer :wheels_count + t.integer :wheels_count, default: 0, null: false + t.datetime :wheels_owned_at t.column :lock_version, :integer, null: false, default: 0 t.timestamps null: false end @@ -189,21 +196,26 @@ ActiveRecord::Schema.define do t.string :resource_id t.string :resource_type t.integer :developer_id + t.datetime :updated_at + t.datetime :deleted_at + t.integer :comments end create_table :companies, force: true do |t| t.string :type - t.integer :firm_id + t.references :firm, index: false t.string :firm_name t.string :name - t.integer :client_of - t.integer :rating, default: 1 + t.bigint :client_of + t.bigint :rating, default: 1 t.integer :account_id t.string :description, default: "" + t.index [:name, :rating], order: :desc + t.index [:name, :description], length: 10 t.index [:firm_id, :type, :rating], name: "company_index", length: { type: 10 }, order: { rating: :desc } t.index [:firm_id, :type], name: "company_partial_index", where: "(rating > 10)" t.index :name, name: "company_name_index", using: :btree - t.index "lower(name)", name: "company_expression_index" if supports_expression_index? + t.index "(CASE WHEN rating > 0 THEN lower(name) END)", name: "company_expression_index" if supports_expression_index? end create_table :content, force: true do |t| @@ -232,8 +244,8 @@ ActiveRecord::Schema.define do end create_table :contracts, force: true do |t| - t.integer :developer_id - t.integer :company_id + t.references :developer, index: false + t.references :company, index: false end create_table :customers, force: true do |t| @@ -259,7 +271,7 @@ ActiveRecord::Schema.define do t.string :name t.string :first_name t.integer :salary, default: 70000 - t.integer :firm_id + t.references :firm, index: false t.integer :mentor_id if subsecond_precision_supported? t.datetime :created_at, precision: 6 @@ -338,6 +350,10 @@ ActiveRecord::Schema.define do t.string :token end + create_table :frogs, force: true do |t| + t.string :name + end + create_table :funny_jokes, force: true do |t| t.string :name end @@ -413,6 +429,9 @@ ActiveRecord::Schema.define do t.string :name end + create_table :kitchens, force: true do |t| + end + create_table :legacy_things, force: true do |t| t.integer :tps_report_number t.integer :version, null: false, default: 0 @@ -450,11 +469,13 @@ ActiveRecord::Schema.define do create_table :lock_without_defaults, force: true do |t| t.column :title, :string t.column :lock_version, :integer + t.timestamps null: true end create_table :lock_without_defaults_cust, force: true do |t| t.column :title, :string t.column :custom_lock_version, :integer + t.timestamps null: true end create_table :magazines, force: true do |t| @@ -468,7 +489,8 @@ ActiveRecord::Schema.define do create_table :members, force: true do |t| t.string :name - t.integer :member_type_id + t.references :member_type, index: false + t.references :admittable, polymorphic: true, index: false end create_table :member_details, force: true do |t| @@ -486,7 +508,7 @@ ActiveRecord::Schema.define do t.datetime :joined_on t.integer :club_id, :member_id t.boolean :favourite, default: false - t.string :type + t.integer :type end create_table :member_types, force: true do |t| @@ -535,7 +557,7 @@ ActiveRecord::Schema.define do create_table :numeric_data, force: true do |t| t.decimal :bank_balance, precision: 10, scale: 2 t.decimal :big_bank_balance, precision: 15, scale: 2 - t.decimal :world_population, precision: 10, scale: 0 + t.decimal :world_population, precision: 20, scale: 0 t.decimal :my_house_population, precision: 2, scale: 0 t.decimal :decimal_number_with_default, precision: 3, scale: 2, default: 2.78 t.float :temperature @@ -678,6 +700,7 @@ ActiveRecord::Schema.define do t.integer :taggings_with_delete_all_count, default: 0 t.integer :taggings_with_destroy_count, default: 0 t.integer :tags_count, default: 0 + t.integer :indestructible_tags_count, default: 0 t.integer :tags_with_destroy_count, default: 0 t.integer :tags_with_nullify_count, default: 0 end @@ -711,7 +734,7 @@ ActiveRecord::Schema.define do create_table :projects, force: true do |t| t.string :name t.string :type - t.integer :firm_id + t.references :firm, index: false t.integer :mentor_id end @@ -783,6 +806,10 @@ ActiveRecord::Schema.define do t.belongs_to :ship end + create_table :sinks, force: true do |t| + t.references :kitchen + end + create_table :shop_accounts, force: true do |t| t.references :customer t.references :customer_carrier @@ -796,20 +823,23 @@ ActiveRecord::Schema.define do create_table :sponsors, force: true do |t| t.integer :club_id - t.integer :sponsorable_id - t.string :sponsorable_type + t.references :sponsorable, polymorphic: true, index: false + t.references :sponsor, polymorphic: true, index: false end - create_table :string_key_objects, id: false, primary_key: :id, force: true do |t| - t.string :id - t.string :name - t.integer :lock_version, null: false, default: 0 + create_table :string_key_objects, id: false, force: true do |t| + t.string :id, null: false + t.string :name + t.integer :lock_version, null: false, default: 0 + t.index :id, unique: true end - create_table :subscribers, force: true, id: false do |t| + create_table :subscribers, id: false, force: true do |t| t.string :nick, null: false t.string :name - t.column :books_count, :integer, null: false, default: 0 + t.integer :id + t.integer :books_count, null: false, default: 0 + t.integer :update_count, null: false, default: 0 t.index :nick, unique: true end @@ -829,6 +859,7 @@ ActiveRecord::Schema.define do t.column :taggable_type, :string t.column :taggable_id, :integer t.string :comment + t.string :type end create_table :tasks, force: true do |t| @@ -931,6 +962,7 @@ ActiveRecord::Schema.define do t.string :poly_man_without_inverse_type t.integer :horrible_polymorphic_man_id t.string :horrible_polymorphic_man_type + t.references :human, polymorphic: true, index: false end create_table :interests, force: true do |t| @@ -946,6 +978,7 @@ ActiveRecord::Schema.define do end create_table :wheels, force: true do |t| + t.integer :size t.references :wheelable, polymorphic: true end diff --git a/activerecord/test/schema/sqlite_specific_schema.rb b/activerecord/test/schema/sqlite_specific_schema.rb new file mode 100644 index 0000000000..18192292e4 --- /dev/null +++ b/activerecord/test/schema/sqlite_specific_schema.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +ActiveRecord::Schema.define do + create_table :defaults, force: true do |t| + t.date :fixed_date, default: "2004-01-01" + t.datetime :fixed_time, default: "2004-01-01 00:00:00" + t.column :char1, "char(1)", default: "Y" + t.string :char2, limit: 50, default: "a varchar field" + t.text :char3, limit: 50, default: "a text field" + end +end diff --git a/activerecord/test/support/config.rb b/activerecord/test/support/config.rb index aaff408b41..bd6d5c339b 100644 --- a/activerecord/test/support/config.rb +++ b/activerecord/test/support/config.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "yaml" require "erb" require "fileutils" diff --git a/activerecord/test/support/connection.rb b/activerecord/test/support/connection.rb index 1a609e13c3..2a4fa53460 100644 --- a/activerecord/test/support/connection.rb +++ b/activerecord/test/support/connection.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/logger" require "models/college" require "models/course" diff --git a/activerecord/test/support/connection_helper.rb b/activerecord/test/support/connection_helper.rb index 4a19e5df44..3bb1b370c1 100644 --- a/activerecord/test/support/connection_helper.rb +++ b/activerecord/test/support/connection_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ConnectionHelper def run_without_connection original_connection = ActiveRecord::Base.remove_connection diff --git a/activerecord/test/support/ddl_helper.rb b/activerecord/test/support/ddl_helper.rb index 43cb235e01..a18bf5ea0a 100644 --- a/activerecord/test/support/ddl_helper.rb +++ b/activerecord/test/support/ddl_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DdlHelper def with_example_table(connection, table_name, definition = nil) connection.execute("CREATE TABLE #{table_name}(#{definition})") diff --git a/activerecord/test/support/schema_dumping_helper.rb b/activerecord/test/support/schema_dumping_helper.rb index 666c1b6a14..777e6a7c1b 100644 --- a/activerecord/test/support/schema_dumping_helper.rb +++ b/activerecord/test/support/schema_dumping_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SchemaDumpingHelper def dump_table_schema(table, connection = ActiveRecord::Base.connection) old_ignore_tables = ActiveRecord::SchemaDumper.ignore_tables |