diff options
Diffstat (limited to 'activerecord')
538 files changed, 29194 insertions, 6639 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index d8bf7df63b..1cf3f3352f 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,21 +1,862 @@ +* Fix circular `autosave: true` causes invalid records to be saved. + + Prior to the fix, when there was a circular series of `autosave: true` + associations, the callback for a `has_many` association was run while + another instance of the same callback on the same association hadn't + finished running. When control returned to the first instance of the + callback, the instance variable had changed, and subsequent associated + records weren't saved correctly. Specifically, the ID field for the + `belongs_to` corresponding to the `has_many` was `nil`. + + Fixes #28080. + + *Larry Reid* + +* Raise `ArgumentError` for invalid `:limit` and `:precision` like as other options. + + Before: + + ```ruby + add_column :items, :attr1, :binary, size: 10 # => ArgumentError + add_column :items, :attr2, :decimal, scale: 10 # => ArgumentError + add_column :items, :attr3, :integer, limit: 10 # => ActiveRecordError + add_column :items, :attr4, :datetime, precision: 10 # => ActiveRecordError + ``` + + After: + + ```ruby + add_column :items, :attr1, :binary, size: 10 # => ArgumentError + add_column :items, :attr2, :decimal, scale: 10 # => ArgumentError + add_column :items, :attr3, :integer, limit: 10 # => ArgumentError + add_column :items, :attr4, :datetime, precision: 10 # => ArgumentError + ``` + + *Ryuta Kamizono* + +* Association loading isn't to be affected by scoping consistently + whether preloaded / eager loaded or not, with the exception of `unscoped`. + + Before: + + ```ruby + Post.where("1=0").scoping do + Comment.find(1).post # => nil + Comment.preload(:post).find(1).post # => #<Post id: 1, ...> + Comment.eager_load(:post).find(1).post # => #<Post id: 1, ...> + end + ``` + + After: + + ```ruby + Post.where("1=0").scoping do + Comment.find(1).post # => #<Post id: 1, ...> + Comment.preload(:post).find(1).post # => #<Post id: 1, ...> + Comment.eager_load(:post).find(1).post # => #<Post id: 1, ...> + end + ``` + + Fixes #34638, #35398. + + *Ryuta Kamizono* + +* Add `rails db:prepare` to migrate or setup a database. + + Runs `db:migrate` if the database exists or `db:setup` if it doesn't. + + *Roberto Miranda* + +* Add `after_save_commit` callback as shortcut for `after_commit :hook, on: [ :create, :update ]`. + + *DHH* + +* Assign all attributes before calling `build` to ensure the child record is visible in + `before_add` and `after_add` callbacks for `has_many :through` associations. + + Fixes #33249. + + *Ryan H. Kerr* + +* Add `ActiveRecord::Relation#extract_associated` for extracting associated records from a relation. + + ``` + account.memberships.extract_associated(:user) + # => Returns collection of User records + ``` + + *DHH* + +* Add `ActiveRecord::Relation#annotate` for adding SQL comments to its queries. + + For example: + + ``` + Post.where(id: 123).annotate("this is a comment").to_sql + # SELECT "posts".* FROM "posts" WHERE "posts"."id" = 123 /* this is a comment */ + ``` + + This can be useful in instrumentation or other analysis of issued queries. + + *Matt Yoho* + +* Support Optimizer Hints. + + In most databases, a way to control the optimizer is by using optimizer hints, + which can be specified within individual statements. + + Example (for MySQL): + + Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)") + # SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics` + + Example (for PostgreSQL with pg_hint_plan): + + Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)") + # SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics" + + See also: + + * https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html + * https://pghintplan.osdn.jp/pg_hint_plan.html + * https://docs.oracle.com/en/database/oracle/oracle-database/12.2/tgsql/influencing-the-optimizer.html + * https://docs.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=sql-server-2017 + * https://www.ibm.com/support/knowledgecenter/en/SSEPGG_11.1.0/com.ibm.db2.luw.admin.perf.doc/doc/c0070117.html + + *Ryuta Kamizono* + +* Fix query attribute method on user-defined attribute to be aware of typecasted value. + + For example, the following code no longer return false as casted non-empty string: + + ``` + class Post < ActiveRecord::Base + attribute :user_defined_text, :text + end + + Post.new(user_defined_text: "false").user_defined_text? # => true + ``` + + *Yuji Kamijima* + +* Quote empty ranges like other empty enumerables. + + *Patrick Rebsch* + +* Add `insert_all`/`insert_all!`/`upsert_all` methods to `ActiveRecord::Persistence`, + allowing bulk inserts akin to the bulk updates provided by `update_all` and + bulk deletes by `delete_all`. + + Supports skipping or upserting duplicates through the `ON CONFLICT` syntax + for PostgreSQL (9.5+) and SQLite (3.24+) and `ON DUPLICATE KEY UPDATE` syntax + for MySQL. + + *Bob Lail* + +* Add `rails db:seed:replant` that truncates tables of each database + for current environment and loads the seeds. + + *bogdanvlviv*, *DHH* + +* Add `ActiveRecord::Base.connection.truncate` for SQLite3 adapter. + + *bogdanvlviv* + +* Deprecate mismatched collation comparison for uniqueness validator. + + Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. + To continue case sensitive comparison on the case insensitive column, + pass `case_sensitive: true` option explicitly to the uniqueness validator. + + *Ryuta Kamizono* + +* Add `reselect` method. This is a short-hand for `unscope(:select).select(fields)`. + + Fixes #27340. + + *Willian Gustavo Veiga* + +* Add negative scopes for all enum values. + + Example: + + class Post < ActiveRecord::Base + enum status: %i[ drafted active trashed ] + end + + Post.not_drafted # => where.not(status: :drafted) + Post.not_active # => where.not(status: :active) + Post.not_trashed # => where.not(status: :trashed) + + *DHH* + +* Fix different `count` calculation when using `size` with manual `select` with DISTINCT. + + Fixes #35214. + + *Juani Villarejo* + + +## Rails 6.0.0.beta3 (March 11, 2019) ## + +* No changes. + + +## Rails 6.0.0.beta2 (February 25, 2019) ## + +* Fix prepared statements caching to be enabled even when query caching is enabled. + + *Ryuta Kamizono* + +* Ensure `update_all` series cares about optimistic locking. + + *Ryuta Kamizono* + +* Don't allow `where` with non numeric string matches to 0 values. + + *Ryuta Kamizono* + +* Introduce `ActiveRecord::Relation#destroy_by` and `ActiveRecord::Relation#delete_by`. + + `destroy_by` allows relation to find all the records matching the condition and perform + `destroy_all` on the matched records. + + Example: + + Person.destroy_by(name: 'David') + Person.destroy_by(name: 'David', rating: 4) + + david = Person.find_by(name: 'David') + david.posts.destroy_by(id: [1, 2, 3]) + + `delete_by` allows relation to find all the records matching the condition and perform + `delete_all` on the matched records. + + Example: + + Person.delete_by(name: 'David') + Person.delete_by(name: 'David', rating: 4) + + david = Person.find_by(name: 'David') + david.posts.delete_by(id: [1, 2, 3]) + + *Abhay Nikam* + +* Don't allow `where` with invalid value matches to nil values. + + Fixes #33624. + + *Ryuta Kamizono* + +* SQLite3: Implement `add_foreign_key` and `remove_foreign_key`. + + *Ryuta Kamizono* + +* Deprecate using class level querying methods if the receiver scope + regarded as leaked. Use `klass.unscoped` to avoid the leaking scope. + + *Ryuta Kamizono* + +* Allow applications to automatically switch connections. + + Adds a middleware and configuration options that can be used in your + application to automatically switch between the writing and reading + database connections. + + `GET` and `HEAD` requests will read from the replica unless there was + a write in the last 2 seconds, otherwise they will read from the primary. + Non-get requests will always write to the primary. The middleware accepts + an argument for a Resolver class and a Operations class where you are able + to change how the auto-switcher works to be most beneficial for your + application. + + To use the middleware in your application you can use the following + configuration options: + + ``` + config.active_record.database_selector = { delay: 2.seconds } + config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver + config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + ``` + + To change the database selection strategy, pass a custom class to the + configuration options: + + ``` + config.active_record.database_selector = { delay: 10.seconds } + config.active_record.database_resolver = MyResolver + config.active_record.database_resolver_context = MyResolver::MyCookies + ``` + + *Eileen M. Uchitelle* + +* MySQL: Support `:size` option to change text and blob size. + + *Ryuta Kamizono* + +* Make `t.timestamps` with precision by default. + + *Ryuta Kamizono* + + +## Rails 6.0.0.beta1 (January 18, 2019) ## + +* Remove deprecated `#set_state` from the transaction object. + + *Rafael Mendonça França* + +* Remove deprecated `#supports_statement_cache?` from the database adapters. + + *Rafael Mendonça França* + +* Remove deprecated `#insert_fixtures` from the database adapters. + + *Rafael Mendonça França* + +* Remove deprecated `ActiveRecord::ConnectionAdapters::SQLite3Adapter#valid_alter_table_type?`. + + *Rafael Mendonça França* + +* Do not allow passing the column name to `sum` when a block is passed. + + *Rafael Mendonça França* + +* Do not allow passing the column name to `count` when a block is passed. + + *Rafael Mendonça França* + +* Remove delegation of missing methods in a relation to arel. + + *Rafael Mendonça França* + +* Remove delegation of missing methods in a relation to private methods of the class. + + *Rafael Mendonça França* + +* Deprecate `config.activerecord.sqlite3.represent_boolean_as_integer`. + + *Rafael Mendonça França* + +* Change `SQLite3Adapter` to always represent boolean values as integers. + + *Rafael Mendonça França* + +* Remove ability to specify a timestamp name for `#cache_key`. + + *Rafael Mendonça França* + +* Remove deprecated `ActiveRecord::Migrator.migrations_path=`. + + *Rafael Mendonça França* + +* Remove deprecated `expand_hash_conditions_for_aggregates`. + + *Rafael Mendonça França* + +* Set polymorphic type column to NULL on `dependent: :nullify` strategy. + + On polymorphic associations both the foreign key and the foreign type columns will be set to NULL. + + *Laerti Papa* + +* Allow permitted instance of `ActionController::Parameters` as argument of `ActiveRecord::Relation#exists?`. + + *Gannon McGibbon* + +* Add support for endless ranges introduces in Ruby 2.6. + + *Greg Navis* + +* Deprecate passing `migrations_paths` to `connection.assume_migrated_upto_version`. + + *Ryuta Kamizono* + +* MySQL: `ROW_FORMAT=DYNAMIC` create table option by default. + + Since MySQL 5.7.9, the `innodb_default_row_format` option defines the default row + format for InnoDB tables. The default setting is `DYNAMIC`. + The row format is required for indexing on `varchar(255)` with `utf8mb4` columns. + + *Ryuta Kamizono* + +* Fix join table column quoting with SQLite. + + *Gannon McGibbon* + +* Allow disabling scopes generated by `ActiveRecord.enum`. + + *Alfred Dominic* + +* Ensure that `delete_all` on collection proxy returns affected count. + + *Ryuta Kamizono* + +* Reset scope after delete on collection association to clear stale offsets of removed records. + + *Gannon McGibbon* + +* Add the ability to prevent writes to a database for the duration of a block. + + Allows the application to prevent writes to a database. This can be useful when + you're building out multiple databases and want to make sure you're not sending + writes when you want a read. + + If `while_preventing_writes` is called and the query is considered a write + query the database will raise an exception regardless of whether the database + user is able to write. + + This is not meant to be a catch-all for write queries but rather a way to enforce + read-only queries without opening a second connection. One purpose of this is to + catch accidental writes, not all writes. + + *Eileen M. Uchitelle* + +* Allow aliased attributes to be used in `#update_columns` and `#update`. + + *Gannon McGibbon* + +* Allow spaces in postgres table names. + + Fixes issue where "user post" is misinterpreted as "\"user\".\"post\"" when quoting table names with the postgres adapter. + + *Gannon McGibbon* + +* Cached `columns_hash` fields should be excluded from `ResultSet#column_types`. + + PR #34528 addresses the inconsistent behaviour when attribute is defined for an ignored column. The following test + was passing for SQLite and MySQL, but failed for PostgreSQL: + + ```ruby + class DeveloperName < ActiveRecord::Type::String + def deserialize(value) + "Developer: #{value}" + end + end + + class AttributedDeveloper < ActiveRecord::Base + self.table_name = "developers" + + attribute :name, DeveloperName.new + + self.ignored_columns += ["name"] + end + + developer = AttributedDeveloper.create + developer.update_column :name, "name" + + loaded_developer = AttributedDeveloper.where(id: developer.id).select("*").first + puts loaded_developer.name # should be "Developer: name" but it's just "name" + ``` + + *Dmitry Tsepelev* + +* Make the implicit order column configurable. + + When calling ordered finder methods such as `first` or `last` without an + explicit order clause, ActiveRecord sorts records by primary key. This can + result in unpredictable and surprising behaviour when the primary key is + not an auto-incrementing integer, for example when it's a UUID. This change + makes it possible to override the column used for implicit ordering such + that `first` and `last` will return more predictable results. + + Example: + + class Project < ActiveRecord::Base + self.implicit_order_column = "created_at" + end + + *Tekin Suleyman* + +* Bump minimum PostgreSQL version to 9.3. + + *Yasuo Honda* + +* Values of enum are frozen, raising an error when attempting to modify them. + + *Emmanuel Byrd* + +* Move `ActiveRecord::StatementInvalid` SQL to error property and include binds as separate error property. + + `ActiveRecord::ConnectionAdapters::AbstractAdapter#translate_exception_class` now requires `binds` to be passed as the last argument. + + `ActiveRecord::ConnectionAdapters::AbstractAdapter#translate_exception` now requires `message`, `sql`, and `binds` to be passed as keyword arguments. + + Subclasses of `ActiveRecord::StatementInvalid` must now provide `sql:` and `binds:` arguments to `super`. + + Example: + + ``` + class MySubclassedError < ActiveRecord::StatementInvalid + def initialize(message, sql:, binds:) + super(message, sql: sql, binds: binds) + end + end + ``` + + *Gannon McGibbon* + +* Add an `:if_not_exists` option to `create_table`. + + Example: + + create_table :posts, if_not_exists: true do |t| + t.string :title + end + + That would execute: + + CREATE TABLE IF NOT EXISTS posts ( + ... + ) + + If the table already exists, `if_not_exists: false` (the default) raises an + exception whereas `if_not_exists: true` does nothing. + + *fatkodima*, *Stefan Kanev* + +* Defining an Enum as a Hash with blank key, or as an Array with a blank value, now raises an `ArgumentError`. + + *Christophe Maximin* + +* Adds support for multiple databases to `rails db:schema:cache:dump` and `rails db:schema:cache:clear`. + + *Gannon McGibbon* + +* `update_columns` now correctly raises `ActiveModel::MissingAttributeError` + if the attribute does not exist. + + *Sean Griffin* + +* Add support for hash and URL configs in database hash of `ActiveRecord::Base.connected_to`. + + ```` + User.connected_to(database: { writing: "postgres://foo" }) do + User.create!(name: "Gannon") + end + + config = { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + User.connected_to(database: { reading: config }) do + User.count + end + ```` + + *Gannon McGibbon* + +* Support default expression for MySQL. + + MySQL 8.0.13 and higher supports default value to be a function or expression. + + https://dev.mysql.com/doc/refman/8.0/en/create-table.html + + *Ryuta Kamizono* + +* Support expression indexes for MySQL. + + MySQL 8.0.13 and higher supports functional key parts that index + expression values rather than column or column prefix values. + + https://dev.mysql.com/doc/refman/8.0/en/create-index.html + + *Ryuta Kamizono* + +* Fix collection cache key with limit and custom select to avoid ambiguous timestamp column error. + + Fixes #33056. + + *Federico Martinez* + +* Add basic API for connection switching to support multiple databases. + + 1) Adds a `connects_to` method for models to connect to multiple databases. Example: + + ``` + class AnimalsModel < ApplicationRecord + self.abstract_class = true + + connects_to database: { writing: :animals_primary, reading: :animals_replica } + end + + class Dog < AnimalsModel + # connected to both the animals_primary db for writing and the animals_replica for reading + end + ``` + + 2) Adds a `connected_to` block method for switching connection roles or connecting to + a database that the model didn't connect to. Connecting to the database in this block is + useful when you have another defined connection, for example `slow_replica` that you don't + want to connect to by default but need in the console, or a specific code block. + + ``` + ActiveRecord::Base.connected_to(role: :reading) do + Dog.first # finds dog from replica connected to AnimalsBase + Book.first # doesn't have a reading connection, will raise an error + end + ``` + + ``` + ActiveRecord::Base.connected_to(database: :slow_replica) do + SlowReplicaModel.first # if the db config has a slow_replica configuration this will be used to do the lookup, otherwise this will throw an exception + end + ``` + + *Eileen M. Uchitelle* + +* Enum raises on invalid definition values + + When defining a Hash enum it can be easy to use `[]` instead of `{}`. This + commit checks that only valid definition values are provided, those can + be a Hash, an array of Symbols or an array of Strings. Otherwise it + raises an `ArgumentError`. + + Fixes #33961 + + *Alberto Almagro* + +* Reloading associations now clears the Query Cache like `Persistence#reload` does. + + ``` + class Post < ActiveRecord::Base + has_one :category + belongs_to :author + has_many :comments + end + + # Each of the following will now clear the query cache. + post.reload_category + post.reload_author + post.comments.reload + ``` + + *Christophe Maximin* + +* Added `index` option for `change_table` migration helpers. + With this change you can create indexes while adding new + columns into the existing tables. + + Example: + + change_table(:languages) do |t| + t.string :country_code, index: true + end + + *Mehmet Emin İNAÇ* + +* Fix `transaction` reverting for migrations. + + Before: Commands inside a `transaction` in a reverted migration ran uninverted. + Now: This change fixes that by reverting commands inside `transaction` block. + + *fatkodima*, *David Verhasselt* + +* Raise an error instead of scanning the filesystem root when `fixture_path` is blank. + + *Gannon McGibbon*, *Max Albrecht* + +* Allow `ActiveRecord::Base.configurations=` to be set with a symbolized hash. + + *Gannon McGibbon* + +* Don't update counter cache unless the record is actually saved. + + Fixes #31493, #33113, #33117. + + *Ryuta Kamizono* + +* Deprecate `ActiveRecord::Result#to_hash` in favor of `ActiveRecord::Result#to_a`. + + *Gannon McGibbon*, *Kevin Cheng* + +* SQLite3 adapter supports expression indexes. + + ``` + create_table :users do |t| + t.string :email + end + + add_index :users, 'lower(email)', name: 'index_users_on_email', unique: true + ``` + + *Gray Kemmey* + +* Allow subclasses to redefine autosave callbacks for associated records. + + Fixes #33305. + + *Andrey Subbota* + +* Bump minimum MySQL version to 5.5.8. + + *Yasuo Honda* + +* Use MySQL utf8mb4 character set by default. + + `utf8mb4` character set with 4-Byte encoding supports supplementary characters including emoji. + The previous default 3-Byte encoding character set `utf8` is not enough to support them. + + *Yasuo Honda* + +* Fix duplicated record creation when using nested attributes with `create_with`. + + *Darwin Wu* + +* Configuration item `config.filter_parameters` could also filter out + sensitive values of database columns when call `#inspect`. + We also added `ActiveRecord::Base::filter_attributes`/`=` in order to + specify sensitive attributes to specific model. + + ``` + Rails.application.config.filter_parameters += [:credit_card_number, /phone/] + Account.last.inspect # => #<Account id: 123, name: "DHH", credit_card_number: [FILTERED], telephone_number: [FILTERED] ...> + SecureAccount.filter_attributes += [:name] + SecureAccount.last.inspect # => #<SecureAccount id: 42, name: [FILTERED], credit_card_number: [FILTERED] ...> + ``` + + *Zhang Kang*, *Yoshiyuki Kinjo* + +* Deprecate `column_name_length`, `table_name_length`, `columns_per_table`, + `indexes_per_table`, `columns_per_multicolumn_index`, `sql_query_length`, + and `joins_per_query` methods in `DatabaseLimits`. + + *Ryuta Kamizono* + +* `ActiveRecord::Base.configurations` now returns an object. + + `ActiveRecord::Base.configurations` used to return a hash, but this + is an inflexible data model. In order to improve multiple-database + handling in Rails, we've changed this to return an object. Some methods + are provided to make the object behave hash-like in order to ease the + transition process. Since most applications don't manipulate the hash + we've decided to add backwards-compatible functionality that will throw + a deprecation warning if used, however calling `ActiveRecord::Base.configurations` + will use the new version internally and externally. + + For example, the following `database.yml`: + + ``` + development: + adapter: sqlite3 + database: db/development.sqlite3 + ``` + + Used to become a hash: + + ``` + { "development" => { "adapter" => "sqlite3", "database" => "db/development.sqlite3" } } + ``` + + Is now converted into the following object: + + ``` + #<ActiveRecord::DatabaseConfigurations:0x00007fd1acbdf800 @configurations=[ + #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 @env_name="development", + @spec_name="primary", @config={"adapter"=>"sqlite3", "database"=>"db/development.sqlite3"}> + ] + ``` + + Iterating over the database configurations has also changed. Instead of + calling hash methods on the `configurations` hash directly, a new method `configs_for` has + been provided that allows you to select the correct configuration. `env_name` and + `spec_name` arguments are optional. For example, these return an array of + database config objects for the requested environment and a single database config object + will be returned for the requested environment and specification name respectively. + + ``` + ActiveRecord::Base.configurations.configs_for(env_name: "development") + ActiveRecord::Base.configurations.configs_for(env_name: "development", spec_name: "primary") + ``` + + *Eileen M. Uchitelle*, *Aaron Patterson* + +* Add database configuration to disable advisory locks. + + ``` + production: + adapter: postgresql + advisory_locks: false + ``` + + *Guo Xiang* + +* SQLite3 adapter `alter_table` method restores foreign keys. + + *Yasuo Honda* + +* Allow `:to_table` option to `invert_remove_foreign_key`. + + Example: + + remove_foreign_key :accounts, to_table: :owners + + *Nikolay Epifanov*, *Rich Chen* + +* Add environment & load_config dependency to `bin/rake db:seed` to enable + seed load in environments without Rails and custom DB configuration + + *Tobias Bielohlawek* + +* Fix default value for mysql time types with specified precision. + + *Nikolay Kondratyev* + +* Fix `touch` option to behave consistently with `Persistence#touch` method. + + *Ryuta Kamizono* + +* Migrations raise when duplicate column definition. + + Fixes #33024. + + *Federico Martinez* + +* Bump minimum SQLite version to 3.8 + + *Yasuo Honda* + +* Fix parent record should not get saved with duplicate children records. + + Fixes #32940. + + *Santosh Wadghule* + +* Fix logic on disabling commit callbacks so they are not called unexpectedly when errors occur. + + *Brian Durand* + +* Ensure `Associations::CollectionAssociation#size` and `Associations::CollectionAssociation#empty?` + use loaded association ids if present. + + *Graham Turner* + +* Add support to preload associations of polymorphic associations when not all the records have the requested associations. + + *Dana Sherson* + +* Add `touch_all` method to `ActiveRecord::Relation`. + + Example: + + Person.where(name: "David").touch_all(time: Time.new(2020, 5, 16, 0, 0, 0)) + + *fatkodima*, *duggiefresh* + * Add `ActiveRecord::Base.base_class?` predicate. *Bogdan Gusiev* -* Add custom prefix option to ActiveRecord::Store.store_accessor. +* Add custom prefix/suffix options to `ActiveRecord::Store.store_accessor`. - *Tan Huynh* + *Tan Huynh*, *Yukio Mizuta* -* Rails 6 requires Ruby 2.4.1 or newer. +* Rails 6 requires Ruby 2.5.0 or newer. - *Jeremy Daer* + *Jeremy Daer*, *Kasper Timm Hansen* * Deprecate `update_attributes`/`!` in favor of `update`/`!`. *Eddie Lebow* -* Add ActiveRecord::Base.create_or_find_by/! to deal with the SELECT/INSERT race condition in - ActiveRecord::Base.find_or_create_by/! by leaning on unique constraints in the database. +* Add `ActiveRecord::Base.create_or_find_by`/`!` to deal with the SELECT/INSERT race condition in + `ActiveRecord::Base.find_or_create_by`/`!` by leaning on unique constraints in the database. *DHH* diff --git a/activerecord/MIT-LICENSE b/activerecord/MIT-LICENSE index cce00cbc3a..79e52c53af 100644 --- a/activerecord/MIT-LICENSE +++ b/activerecord/MIT-LICENSE @@ -1,4 +1,6 @@ -Copyright (c) 2004-2018 David Heinemeier Hansson +Copyright (c) 2004-2019 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 19650b82ae..be573af4ba 100644 --- a/activerecord/README.rdoc +++ b/activerecord/README.rdoc @@ -13,6 +13,8 @@ columns. Although these mappings can be defined explicitly, it's recommended to follow naming conventions, especially when getting started with the library. +You can read more about Active Record in the {Active Record Basics}[https://edgeguides.rubyonrails.org/active_record_basics.html] guide. + A short rundown of some of the major features: * Automated mapping between classes and tables, attributes and columns. @@ -206,7 +208,7 @@ Active Record is released under the MIT license: API documentation is at: -* http://api.rubyonrails.org +* https://api.rubyonrails.org Bug reports for the Ruby on Rails project can be filed here: diff --git a/activerecord/RUNNING_UNIT_TESTS.rdoc b/activerecord/RUNNING_UNIT_TESTS.rdoc index 60561e2c0f..37473c37c6 100644 --- a/activerecord/RUNNING_UNIT_TESTS.rdoc +++ b/activerecord/RUNNING_UNIT_TESTS.rdoc @@ -1,7 +1,7 @@ == Setup If you don't have an environment for running tests, read -http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#setting-up-a-development-environment +https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#setting-up-a-development-environment == Running the Tests diff --git a/activerecord/Rakefile b/activerecord/Rakefile index 170c95b827..f259ae7e12 100644 --- a/activerecord/Rakefile +++ b/activerecord/Rakefile @@ -9,11 +9,9 @@ def run_without_aborting(*tasks) errors = [] tasks.each do |task| - begin - Rake::Task[task].invoke - rescue Exception - errors << task - end + Rake::Task[task].invoke + rescue Exception + errors << task end abort "Errors running #{errors.join(', ')}" if errors.any? @@ -67,11 +65,90 @@ end task adapter => "#{adapter}:env" do adapter_short = adapter == "db2" ? adapter : adapter[/^[a-z0-9]+/] puts [adapter, adapter_short].inspect - (Dir["test/cases/**/*_test.rb"].reject { + + dash_i = [ + "test", + "lib", + "../activesupport/lib", + "../activemodel/lib" + ].map { |dir| File.expand_path(dir, __dir__) } + + dash_i.reverse_each do |x| + $:.unshift(x) unless $:.include?(x) + end + $-w = true + + require "bundler/setup" unless defined?(Bundler) + + # Every test file loads "cases/helper" first, so doing it + # post-fork gains us nothing. + + # We need to dance around minitest autorun, though. + require "minitest" + Minitest.instance_eval do + alias _original_autorun autorun + def autorun + # no-op + end + require "cases/helper" + alias autorun _original_autorun + end + + failing_files = [] + + test_options = ENV["TESTOPTS"].to_s.split(/[\s]+/) + + test_files = (Dir["test/cases/**/*_test.rb"].reject { |x| x.include?("/adapters/") - } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).all? do |file| - sh(Gem.ruby, "-w", "-Itest", file) - end || raise("Failures") + } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).sort + + if ENV["BUILDKITE_PARALLEL_JOB_COUNT"] + n = ENV["BUILDKITE_PARALLEL_JOB"].to_i + m = ENV["BUILDKITE_PARALLEL_JOB_COUNT"].to_i + + test_files = test_files.each_slice(m).map { |slice| slice[n] }.compact + end + + test_files.each do |file| + puts "--- #{file}" + fake_command = Shellwords.join([ + FileUtils::RUBY, + "-w", + *dash_i.map { |dir| "-I#{Pathname.new(dir).relative_path_from(Pathname.pwd)}" }, + file, + ]) + puts fake_command + + # We could run these in parallel, but pretty much all of the + # railties tests already run in parallel, so ¯\_(⊙︿⊙)_/¯ + Process.waitpid fork { + ARGV.clear.concat test_options + Rake.application = nil + + Minitest.autorun + + load file + } + + unless $?.success? + failing_files << file + puts "^^^ +++" + end + puts + end + + puts "--- All tests completed" + unless failing_files.empty? + puts "^^^ +++" + puts + puts "Failed in:" + failing_files.each do |file| + puts " #{file}" + end + puts + + exit 1 + end end end end @@ -91,18 +168,23 @@ end namespace :db do namespace :mysql do + connection_arguments = lambda do |connection_name| + config = ARTest.config["connections"]["mysql2"][connection_name] + ["--user=#{config["username"]}", "--password=#{config["password"]}", ("--host=#{config["host"]}" if config["host"])].join(" ") + end + 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 #{connection_arguments["arunit"]} -e "create DATABASE #{config["arunit"]["database"]} DEFAULT CHARACTER SET utf8mb4" ) + %x( mysql #{connection_arguments["arunit2"]} -e "create DATABASE #{config["arunit2"]["database"]} DEFAULT CHARACTER SET utf8mb4" ) end desc "Drop the MySQL test databases" task :drop do config = ARTest.config["connections"]["mysql2"] - %x( mysqladmin --user=#{config["arunit"]["username"]} --password=#{config["arunit"]["password"]} -f drop #{config["arunit"]["database"]} ) - %x( mysqladmin --user=#{config["arunit2"]["username"]} --password=#{config["arunit2"]["password"]} -f drop #{config["arunit2"]["database"]} ) + %x( mysqladmin #{connection_arguments["arunit"]} -f drop #{config["arunit"]["database"]} ) + %x( mysqladmin #{connection_arguments["arunit2"]} -f drop #{config["arunit2"]["database"]} ) end desc "Rebuild the MySQL test databases" diff --git a/activerecord/activerecord.gemspec b/activerecord/activerecord.gemspec index b43e7c50f5..f73233c38b 100644 --- a/activerecord/activerecord.gemspec +++ b/activerecord/activerecord.gemspec @@ -9,13 +9,13 @@ 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.4.1" + s.required_ruby_version = ">= 2.5.0" s.license = "MIT" s.author = "David Heinemeier Hansson" s.email = "david@loudthinking.com" - s.homepage = "http://rubyonrails.org" + s.homepage = "https://rubyonrails.org" s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.rdoc", "examples/**/*", "lib/**/*"] s.require_path = "lib" @@ -28,8 +28,9 @@ Gem::Specification.new do |s| "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/activerecord/CHANGELOG.md" } + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + s.add_dependency "activesupport", version s.add_dependency "activemodel", version - - s.add_dependency "arel", ">= 9.0" end diff --git a/activerecord/examples/performance.rb b/activerecord/examples/performance.rb index 1a2c78f39b..024e503ec7 100644 --- a/activerecord/examples/performance.rb +++ b/activerecord/examples/performance.rb @@ -176,7 +176,7 @@ Benchmark.ips(TIME) do |x| end x.report "Model.log" do - Exhibit.connection.send(:log, "hello", "world") {} + Exhibit.connection.send(:log, "hello", "world") { } end x.report "AR.execute(query)" do diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index d198466dbf..fd8d2edf28 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true #-- -# Copyright (c) 2004-2018 David Heinemeier Hansson +# Copyright (c) 2004-2019 David Heinemeier Hansson # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -40,7 +40,6 @@ module ActiveRecord autoload :Core autoload :ConnectionHandling autoload :CounterCache - autoload :DatabaseConfigurations autoload :DynamicMatchers autoload :Enum autoload :InternalMetadata @@ -56,7 +55,6 @@ module ActiveRecord autoload :Persistence autoload :QueryCache autoload :Querying - autoload :CollectionCacheKey autoload :ReadonlyAttributes autoload :RecordInvalid, "active_record/validations" autoload :Reflection @@ -75,6 +73,7 @@ module ActiveRecord autoload :Translation autoload :Validations autoload :SecureToken + autoload :DatabaseSelector, "active_record/middleware/database_selector" eager_autoload do autoload :ActiveRecordError, "active_record/errors" @@ -154,6 +153,12 @@ module ActiveRecord end end + module Middleware + extend ActiveSupport::Autoload + + autoload :DatabaseSelector, "active_record/middleware/database_selector" + end + module Tasks extend ActiveSupport::Autoload diff --git a/activerecord/lib/active_record/aggregations.rb b/activerecord/lib/active_record/aggregations.rb index 27a641f05b..3250e29b82 100644 --- a/activerecord/lib/active_record/aggregations.rb +++ b/activerecord/lib/active_record/aggregations.rb @@ -3,8 +3,6 @@ module ActiveRecord # See ActiveRecord::Aggregations::ClassMethods for documentation module Aggregations - extend ActiveSupport::Concern - def initialize_dup(*) # :nodoc: @aggregation_cache = {} super @@ -225,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 403667fb70..4c538ef2bd 100644 --- a/activerecord/lib/active_record/association_relation.rb +++ b/activerecord/lib/active_record/association_relation.rb @@ -31,9 +31,9 @@ module ActiveRecord private def exec_queries - super do |r| - @association.set_inverse_instance r - yield r if block_given? + super do |record| + @association.set_inverse_instance_from_queries(record) + yield record if block_given? end end end diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index b1cad0d0a4..64c20adc87 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -241,7 +241,7 @@ module ActiveRecord association end - def association_cached?(name) # :nodoc + def association_cached?(name) # :nodoc: @association_cache.key?(name) end @@ -292,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 # @@ -703,8 +703,9 @@ module ActiveRecord # #belongs_to associations. # # Extra options on the associations, as defined in the - # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> constant, will - # also prevent the association's inverse from being found automatically. + # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> + # constant, or a custom scope, will also prevent the association's inverse + # from being found automatically. # # The automatic guessing of the inverse association uses a heuristic based # on the name of the class, so it may not work for all associations, @@ -1232,9 +1233,9 @@ 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. # @@ -1293,8 +1294,9 @@ module ActiveRecord # # * <tt>:destroy</tt> causes all the associated objects to also be destroyed. # * <tt>:delete_all</tt> causes all the associated objects to be deleted directly from the database (so callbacks will not be executed). - # * <tt>:nullify</tt> causes the foreign keys to be set to +NULL+. Callbacks are not executed. - # * <tt>:restrict_with_exception</tt> causes an exception to be raised if there are any associated records. + # * <tt>:nullify</tt> causes the foreign keys to be set to +NULL+. Polymorphic type will also be nullified + # on polymorphic associations. Callbacks are not executed. + # * <tt>:restrict_with_exception</tt> causes an <tt>ActiveRecord::DeleteRestrictionError</tt> exception to be raised if there are any associated records. # * <tt>:restrict_with_error</tt> causes an error to be added to the owner if there are any associated objects. # # If using with the <tt>:through</tt> option, the association on the join model must be @@ -1405,9 +1407,9 @@ module ActiveRecord # 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 @@ -1436,8 +1438,9 @@ module ActiveRecord # # * <tt>:destroy</tt> causes the associated object to also be destroyed # * <tt>:delete</tt> causes the associated object to be deleted directly from the database (so callbacks will not execute) - # * <tt>:nullify</tt> causes the foreign key to be set to +NULL+. Callbacks are not executed. - # * <tt>:restrict_with_exception</tt> causes an exception to be raised if there is an associated record + # * <tt>:nullify</tt> causes the foreign key to be set to +NULL+. Polymorphic type column is also nullified + # on polymorphic associations. Callbacks are not executed. + # * <tt>:restrict_with_exception</tt> causes an <tt>ActiveRecord::DeleteRestrictionError</tt> exception to be raised if there is an associated record # * <tt>:restrict_with_error</tt> causes an error to be added to the owner if there is an associated object # # Note that <tt>:dependent</tt> option is ignored when using <tt>:through</tt> option. @@ -1524,6 +1527,7 @@ module ActiveRecord # 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. + # No modification or deletion of existing records takes place. # [build_association(attributes = {})] # Returns a new object of the associated type that has been instantiated # with +attributes+ and linked to this object through a foreign key, but has not yet been saved. @@ -1581,7 +1585,7 @@ module ActiveRecord # association will use "taggable_type" as the default <tt>:foreign_type</tt>. # [:primary_key] # Specify the method that returns the primary key of associated object used for the association. - # By default this is id. + # By default this is +id+. # [:dependent] # If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to # <tt>:delete</tt>, the associated object is deleted *without* calling its destroy method. @@ -1746,8 +1750,8 @@ 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. # @@ -1761,6 +1765,7 @@ module ActiveRecord # has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) } # has_and_belongs_to_many :categories, ->(post) { # where("default_category = ?", post.default_category) + # } # # === Extensions # diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 4f3893588e..272eede824 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -33,7 +33,7 @@ module ActiveRecord elsif join.is_a?(Arel::Nodes::Join) join.left.name == name ? 1 : 0 elsif join.is_a?(Hash) - join.fetch(name, 0) + join[name] else raise ArgumentError, "joins list should be initialized by list of Arel::Nodes::Join" end diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index ca8c7794e0..cf22b850b9 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -17,9 +17,25 @@ module ActiveRecord # CollectionAssociation # HasManyAssociation + ForeignAssociation # HasManyThroughAssociation + ThroughAssociation + # + # Associations in Active Record are middlemen between the object that + # holds the association, known as the <tt>owner</tt>, and the associated + # result set, known as the <tt>target</tt>. Association metadata is available in + # <tt>reflection</tt>, which is an instance of <tt>ActiveRecord::Reflection::AssociationReflection</tt>. + # + # For example, given + # + # class Blog < ActiveRecord::Base + # has_many :posts + # end + # + # blog = Blog.first + # + # The association of <tt>blog.posts</tt> has the object +blog+ as its + # <tt>owner</tt>, the collection of its posts as <tt>target</tt>, and + # the <tt>reflection</tt> object represents a <tt>:has_many</tt> macro. class Association #:nodoc: attr_reader :owner, :target, :reflection - attr_accessor :inversed delegate :options, to: :reflection @@ -41,7 +57,9 @@ module ActiveRecord end # Reloads the \target and returns +self+ on success. - def reload + # The QueryCache is cleared if +force+ is true. + def reload(force = false) + klass.connection.clear_query_cache if force && klass reset reset_scope load_target @@ -67,7 +85,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+. @@ -80,53 +98,44 @@ module ActiveRecord target_scope.merge!(association_scope) end - # The scope for this association. - # - # Note that the association_scope is merged into the target_scope only when the - # scope method is called. This is because at that point the call may be surrounded - # by scope.scoping { ... } or with_scope { ... } etc, which affects the scope which - # actually gets built. - def association_scope - if klass - @association_scope ||= AssociationScope.scope(self) - end - end - def reset_scope @association_scope = nil end # 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 + + def set_inverse_instance_from_queries(record) + if inverse = inverse_association_for(record) + inverse.inversed_from_queries(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 + alias :inversed_from_queries :inversed_from + # Returns the class of the target. belongs_to polymorphic overrides this to look at the # polymorphic_type field on the owner. def klass reflection.klass end - # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the - # through association's scope) - def target_scope - AssociationRelation.create(klass, self).merge!(klass.all) - end - def extensions extensions = klass.default_extensions | reflection.extensions @@ -187,6 +196,38 @@ module ActiveRecord end private + def find_target + scope = self.scope + return scope.to_a if skip_statement_cache?(scope) + + conn = klass.connection + 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, conn) { |record| set_inverse_instance(record) } || [] + end + + # The scope for this association. + # + # Note that the association_scope is merged into the target_scope only when the + # scope method is called. This is because at that point the call may be surrounded + # by scope.scoping { ... } or unscoped { ... } etc, which affects the scope which + # actually gets built. + def association_scope + if klass + @association_scope ||= AssociationScope.scope(self) + end + end + + # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the + # through association's scope) + def target_scope + AssociationRelation.create(klass, self).merge!(klass.scope_for_association) + end + def scope_for_create scope.scope_for_create end @@ -240,6 +281,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. @@ -269,6 +316,7 @@ module ActiveRecord def build_record(attributes) reflection.build_association(attributes) do |record| initialize_attributes(record, attributes) + yield(record) if block_given? end end diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index 0a90a6104a..9e38380611 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -26,7 +26,9 @@ module ActiveRecord chain = get_chain(reflection, association, scope.alias_tracker) scope.extending! reflection.extensions - add_constraints(scope, owner, chain) + scope = add_constraints(scope, owner, chain) + scope.limit!(1) unless reflection.collection? + scope end def self.get_bind_values(owner, chain) diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 1109fee462..3346725f2d 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -16,20 +16,7 @@ module ActiveRecord end end - 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 - - self.target = record - end - - def target=(record) + def inversed_from(record) replace_keys(record) super end @@ -47,26 +34,60 @@ module ActiveRecord @updated end - def decrement_counters # :nodoc: + def decrement_counters update_counters(-1) end - def increment_counters # :nodoc: + def increment_counters update_counters(1) end + def decrement_counters_before_last_save + if reflection.polymorphic? + model_was = owner.attribute_before_last_save(reflection.foreign_type).try(:constantize) + else + model_was = klass + end + + foreign_key_was = owner.attribute_before_last_save(reflection.foreign_key) + + if foreign_key_was && model_was < ActiveRecord::Base + update_counters_via_scope(model_was, foreign_key_was, -1) + end + end + + def target_changed? + owner.saved_change_to_attribute?(reflection.foreign_key) + end + private + def replace(record) + if record + raise_on_type_mismatch!(record) + set_inverse_instance(record) + @updated = true + 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, touch: reflection.options[:touch]) else - klass.update_counters(target_id, reflection.counter_cache_column => by, touch: reflection.options[:touch]) + update_counters_via_scope(klass, owner._read_attribute(reflection.foreign_key), by) end end end + def update_counters_via_scope(klass, foreign_key, by) + scope = klass.unscoped.where!(primary_key(klass) => foreign_key) + scope.update_counters(reflection.counter_cache_column => by, touch: reflection.options[:touch]) + end + def find_target? !loaded? && foreign_key_present? && klass end @@ -75,22 +96,12 @@ module ActiveRecord reflection.counter_cache_column && owner.persisted? end - 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) - 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) + def replace_keys(record) + owner[reflection.foreign_key] = record ? record._read_attribute(primary_key(record.class)) : nil end - def replace_keys(record) - owner[reflection.foreign_key] = record ? - record._read_attribute(reflection.association_primary_key(record.class)) : nil + def primary_key(klass) + reflection.association_primary_key(klass) end def foreign_key_present? @@ -104,14 +115,6 @@ 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 - end - def stale_state result = owner._read_attribute(reflection.foreign_key) { |n| owner.send(:missing_attribute, n, caller) } result && result.to_s 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 75b4c4481a..9ae452e7a1 100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb @@ -9,17 +9,16 @@ module ActiveRecord 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 ? record.class.polymorphic_name : nil end - def different_target?(record) - super || record.class != klass - end - def inverse_reflection_for(record) reflection.polymorphic_inverse_of(record.class) end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index c161454c1a..fc00f1e900 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -21,50 +21,16 @@ module ActiveRecord::Associations::Builder # :nodoc: add_default_callbacks(model, reflection) if reflection.options[:default] end - def self.define_accessors(mixin, reflection) - super - add_counter_cache_methods mixin - end - - def self.add_counter_cache_methods(mixin) - return if mixin.method_defined? :belongs_to_counter_cache_after_update - - mixin.class_eval do - def belongs_to_counter_cache_after_update(reflection) - foreign_key = reflection.foreign_key - cache_column = reflection.counter_cache_column - - if (@_after_replace_counter_called ||= false) - @_after_replace_counter_called = false - elsif saved_change_to_attribute?(foreign_key) && !new_record? - if reflection.polymorphic? - model = attribute_in_database(reflection.foreign_type).try(:constantize) - model_was = attribute_before_last_save(reflection.foreign_type).try(:constantize) - else - model = reflection.klass - model_was = reflection.klass - end - - 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) - end - - if foreign_key_was && model_was.respond_to?(:decrement_counter) - model_was.decrement_counter(cache_column, foreign_key_was) - end - end - end - end - end - def self.add_counter_cache_callbacks(model, reflection) cache_column = reflection.counter_cache_column model.after_update lambda { |record| - record.belongs_to_counter_cache_after_update(reflection) + association = association(reflection.name) + + if association.target_changed? + association.increment_counters + association.decrement_counters_before_last_save + end } klass = reflection.class_name.safe_constantize @@ -84,7 +50,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 @@ -114,12 +81,18 @@ module ActiveRecord::Associations::Builder # :nodoc: BelongsTo.touch_record(record, record.send(changes_method), foreign_key, n, touch, belongs_to_touch_method) }} - unless reflection.counter_cache_column + if reflection.counter_cache_column + touch_callback = callback.(:saved_changes) + update_callback = lambda { |record| + instance_exec(record, &touch_callback) unless association(reflection.name).target_changed? + } + model.after_update update_callback, if: :saved_changes? + else model.after_create callback.(:saved_changes), if: :saved_changes? + model.after_update 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 diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index 35a72c3850..5848cd9112 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -20,11 +20,11 @@ module ActiveRecord::Associations::Builder # :nodoc: } end - def self.define_extensions(model, name) + def self.define_extensions(model, name, &block) if block_given? extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension" - extension = Module.new(&Proc.new) - model.parent.const_set(extension_module_name, extension) + extension = Module.new(&block) + model.module_parent.const_set(extension_module_name, extension) end end 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 1981da11a2..0140aa15c8 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 @@ -2,39 +2,6 @@ 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) @@ -44,8 +11,6 @@ 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 attr_accessor :left_model @@ -56,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) @@ -86,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 @@ -96,7 +63,7 @@ module ActiveRecord::Associations::Builder # :nodoc: def middle_reflection(join_model) middle_name = [lhs_model.name.downcase.pluralize, - association_name].join("_".freeze).gsub("::".freeze, "_".freeze).to_sym + association_name].join("_").gsub("::", "_").to_sym middle_options = middle_options join_model HasMany.create_reflection(lhs_model, @@ -117,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/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index 443ccaaa72..c3d4eab562 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -45,6 +45,8 @@ 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 ||= scope.pluck(reflection.association_primary_key) end @@ -103,15 +105,12 @@ 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 - # Add +records+ to this association. Returns +self+ so method calls may - # be chained. Since << flattens its argument list and inserts each record, - # +push+ and +concat+ behave identically. + # Add +records+ to this association. Since +<<+ flattens its argument list + # and inserts each record, +push+ and +concat+ behave identically. def concat(*records) records = records.flatten if owner.new_record? @@ -212,9 +211,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 @@ -231,10 +232,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 || reflection.has_cached_counter? size.zero? else - @target.blank? && !scope.exists? + target.empty? && !scope.exists? end end @@ -301,23 +302,6 @@ module ActiveRecord end private - - def find_target - scope = self.scope - return scope.to_a if skip_statement_cache?(scope) - - conn = klass.connection - 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, conn) do |record| - set_inverse_instance(record) - end - end - # We have some records loaded from the database (persisted) and some that are # in-memory (memory). The same record may be represented in the persisted array # and in the memory array. @@ -356,15 +340,17 @@ 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 @@ -395,7 +381,8 @@ 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 + @association_ids = nil records.each { |record| callback(:after_remove, record) } end @@ -408,9 +395,9 @@ module ActiveRecord end def replace_records(new_target, original_target) - delete(target - new_target) + delete(difference(target, new_target)) - unless concat(new_target - target) + unless concat(difference(new_target, target)) @target = original_target raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \ "new records could not be saved." @@ -420,7 +407,7 @@ module ActiveRecord end def replace_common_records_in_memory(new_target, original_target) - common_records = new_target & original_target + common_records = intersection(new_target, original_target) common_records.each do |record| skip_callbacks = true replace_on_target(record, @target.index(record), skip_callbacks) @@ -436,13 +423,14 @@ module ActiveRecord 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) @@ -457,6 +445,7 @@ module ActiveRecord if index target[index] = record elsif @_was_loaded || !loaded? + @association_ids = nil target << record end diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index 9a30198b95..edcb44f0fc 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -2,11 +2,8 @@ module ActiveRecord module Associations - # Association proxies in Active Record are middlemen between the object that - # holds the association, known as the <tt>@owner</tt>, and the actual associated - # object, known as the <tt>@target</tt>. The kind of association any proxy is - # about is available in <tt>@reflection</tt>. That's an instance of the class - # ActiveRecord::Reflection::AssociationReflection. + # Collection proxies in Active Record are middlemen between an + # <tt>association</tt>, and its <tt>target</tt> result set. # # For example, given # @@ -16,14 +13,14 @@ module ActiveRecord # # blog = Blog.first # - # the association proxy in <tt>blog.posts</tt> has the object in +blog+ as - # <tt>@owner</tt>, the collection of its posts as <tt>@target</tt>, and - # the <tt>@reflection</tt> object represents a <tt>:has_many</tt> macro. + # The collection proxy returned by <tt>blog.posts</tt> is built from a + # <tt>:has_many</tt> <tt>association</tt>, and delegates to a collection + # of posts as the <tt>target</tt>. # - # This class delegates unknown methods to <tt>@target</tt> via - # <tt>method_missing</tt>. + # This class delegates unknown methods to the <tt>association</tt>'s + # relation class via a delegate cache. # - # The <tt>@target</tt> object is not \loaded until needed. For example, + # The <tt>target</tt> result set is not loaded until needed. For example, # # blog.posts.count # @@ -366,34 +363,6 @@ module ActiveRecord @association.create!(attributes, &block) end - # Add one or more records to the collection by setting their foreign keys - # to the association's primary key. Since #<< flattens its argument list and - # inserts each record, +push+ and #concat behave identically. Returns +self+ - # so method calls may be chained. - # - # class Person < ActiveRecord::Base - # has_many :pets - # end - # - # person.pets.size # => 0 - # person.pets.concat(Pet.new(name: 'Fancy-Fancy')) - # person.pets.concat(Pet.new(name: 'Spook'), Pet.new(name: 'Choo-Choo')) - # person.pets.size # => 3 - # - # person.id # => 1 - # person.pets - # # => [ - # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>, - # # #<Pet id: 2, name: "Spook", person_id: 1>, - # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> - # # ] - # - # person.pets.concat([Pet.new(name: 'Brain'), Pet.new(name: 'Benny')]) - # person.pets.size # => 5 - def concat(*records) - @association.concat(*records) - end - # Replaces this collection with +other_array+. This will perform a diff # and delete/add only records that have changed. # @@ -500,7 +469,7 @@ module ActiveRecord # Pet.find(1, 2, 3) # # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3) def delete_all(dependent = nil) - @association.delete_all(dependent) + @association.delete_all(dependent).tap { reset_scope } end # Deletes the records of the collection directly from the database @@ -527,7 +496,7 @@ module ActiveRecord # # Pet.find(1) # => Couldn't find Pet with id=1 def destroy_all - @association.destroy_all + @association.destroy_all.tap { reset_scope } end # Deletes the +records+ supplied from the collection according to the strategy @@ -646,7 +615,7 @@ module ActiveRecord # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> # # ] def delete(*records) - @association.delete(*records) + @association.delete(*records).tap { reset_scope } end # Destroys the +records+ supplied and removes them from the collection. @@ -718,7 +687,7 @@ module ActiveRecord # # Pet.find(4, 5, 6) # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (4, 5, 6) def destroy(*records) - @association.destroy(*records) + @association.destroy(*records).tap { reset_scope } end ## @@ -1033,8 +1002,9 @@ module ActiveRecord end # Adds one or more +records+ to the collection by setting their foreign keys - # to the association's primary key. Returns +self+, so several appends may be - # chained together. + # to the association's primary key. Since +<<+ flattens its argument list and + # inserts each record, +push+ and +concat+ behave identically. Returns +self+ + # so several appends may be chained together. # # class Person < ActiveRecord::Base # has_many :pets @@ -1057,6 +1027,7 @@ module ActiveRecord end alias_method :push, :<< alias_method :append, :<< + alias_method :concat, :<< def prepend(*args) raise NoMethodError, "prepend on association is not defined. Please use <<, push or append" @@ -1088,7 +1059,7 @@ module ActiveRecord # person.pets.reload # fetches pets from the database # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] def reload - proxy_association.reload + proxy_association.reload(true) reset_scope end @@ -1125,7 +1096,7 @@ module ActiveRecord SpawnMethods, ].flat_map { |klass| klass.public_instance_methods(false) - } - self.public_instance_methods(false) - [:select] + [:scoping] + } - self.public_instance_methods(false) - [:select] + [:scoping, :values] delegate(*delegate_methods, to: :scope) diff --git a/activerecord/lib/active_record/associations/foreign_association.rb b/activerecord/lib/active_record/associations/foreign_association.rb index 40010cde03..59af6f54c3 100644 --- a/activerecord/lib/active_record/associations/foreign_association.rb +++ b/activerecord/lib/active_record/associations/foreign_association.rb @@ -9,5 +9,12 @@ module ActiveRecord::Associations false end end + + def nullified_owner_attributes + Hash.new.tap do |attrs| + attrs[reflection.foreign_key] = nil + attrs[reflection.type] = nil if reflection.type.present? + end + end end end diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index cf85a87fa7..5972846940 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -36,14 +36,6 @@ module ActiveRecord super end - def empty? - if reflection.has_cached_counter? - size.zero? - else - super - end - end - private # Returns the number of records in this collection. @@ -69,7 +61,7 @@ module ActiveRecord # If there's nothing in the database and @target has no new records # we are certain the current target is an empty array. This is a # documented side-effect of the method that may avoid an extra SELECT. - (@target ||= []) && loaded! if count == 0 + loaded! if count == 0 [association_scope.limit_value, count].compact.min end @@ -92,13 +84,14 @@ module ActiveRecord if method == :delete_all scope.delete_all else - scope.update_all(reflection.foreign_key => nil) + scope.update_all(nullified_owner_attributes) end end def delete_or_nullify_all_records(method) count = delete_count(method, scope) update_counter(-count) + count end # Deletes the records according to the <tt>:dependent</tt> option. @@ -130,6 +123,14 @@ module ActiveRecord end saved_successfully end + + def difference(a, b) + a - b + end + + def intersection(a, b) + a & b + end end end end 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 59929b8c4e..0d384950fe 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -21,20 +21,6 @@ module ActiveRecord super end - def concat_records(records) - ensure_not_nested - - records = super(records, true) - - if owner.new_record? && records - records.flatten.each do |record| - build_through_record(record) - end - end - - records - end - def insert_record(record, validate = true, raise = false) ensure_not_nested @@ -48,6 +34,20 @@ module ActiveRecord end private + def concat_records(records) + ensure_not_nested + + records = super(records, true) + + if owner.new_record? && records + records.flatten.each do |record| + build_through_record(record) + end + end + + records + end + # The through record (built with build_record) is temporarily cached # so that it may be reused if insert_record is subsequently called. # @@ -57,21 +57,14 @@ module ActiveRecord @through_records[record.object_id] ||= begin ensure_mutable - through_record = through_association.build(*options_for_through_record) - through_record.send("#{source_reflection.name}=", record) + attributes = through_scope_attributes + attributes[source_reflection.name] = record + attributes[source_reflection.foreign_type] = options[:source_type] if options[:source_type] - if options[:source_type] - through_record.send("#{source_reflection.foreign_type}=", options[:source_type]) - end - - through_record + through_association.build(attributes) end end - def options_for_through_record - [through_scope_attributes] - end - def through_scope_attributes scope.where_values_hash(through_association.reflection.name.to_s). except!(through_association.reflection.foreign_key, @@ -90,7 +83,7 @@ module ActiveRecord def build_record(attributes) ensure_not_nested - record = super(attributes) + record = super inverse = source_reflection.inverse_of if inverse @@ -161,6 +154,30 @@ module ActiveRecord else update_counter(-count) end + + count + end + + def difference(a, b) + distribution = distribution(b) + + a.reject { |record| mark_occurrence(distribution, record) } + end + + def intersection(a, b) + distribution = distribution(b) + + a.select { |record| mark_occurrence(distribution, record) } + end + + def mark_occurrence(distribution, record) + distribution[record] > 0 && distribution[record] -= 1 + end + + def distribution(array) + array.each_with_object(Hash.new(0)) do |record, distribution| + distribution[record] += 1 + end end def through_records_for(record) diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index 090b082cb0..99971286a3 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -23,35 +23,6 @@ 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 @@ -62,12 +33,39 @@ module ActiveRecord target.destroy throw(:abort) unless target.destroyed? when :nullify - target.update_columns(reflection.foreign_key => nil) if target.persisted? + target.update_columns(nullified_owner_attributes) if target.persisted? end end 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 @@ -107,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 491282adf7..10978b2d93 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -6,12 +6,12 @@ module ActiveRecord class HasOneThroughAssociation < HasOneAssociation #:nodoc: include ThroughAssociation - def replace(record, save = true) - create_through_record(record, save) - self.target = record - end - private + def replace(record, save = true) + create_through_record(record, save) + self.target = record + end + def create_through_record(record, save) ensure_not_nested @@ -28,7 +28,11 @@ module ActiveRecord end if through_record - through_record.update(attributes) + 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 diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index f88e383fe0..b76005b587 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -14,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 @@ -25,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) @@ -67,42 +64,31 @@ module ActiveRecord end end - def initialize(base, table, associations, alias_tracker) - @alias_tracker = alias_tracker + def initialize(base, table, associations) tree = self.class.make_tree associations @join_root = JoinBase.new(base, table, build(tree, base)) - @join_root.children.each { |child| construct_tables! @join_root, child } end def reflections join_root.drop(1).map!(&:reflection) end - def join_constraints(joins_to_add, join_type) - joins = join_root.children.flat_map { |child| - make_join_constraints(join_root, child, join_type) - } + def join_constraints(joins_to_add, join_type, alias_tracker) + @alias_tracker = alias_tracker + + construct_tables!(join_root) + joins = make_join_constraints(join_root, join_type) 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_join_constraints(oj.join_root, child, join_type) - } + make_join_constraints(oj.join_root, join_type) end } end - 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 instantiate(result_set, &block) primary_key = aliases.column_alias(join_root, join_root.primary_key) @@ -127,35 +113,49 @@ module ActiveRecord 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, &block) - construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases) + construct(parent, join_root, row_hash, seen, model_cache) } end parents.values end + def apply_column_aliases(relation) + relation._select!(-> { aliases.columns }) + end + protected - attr_reader :alias_tracker, :base_klass, :join_root + attr_reader :join_root private + attr_reader :alias_tracker - 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, join_type, tables, chain) + 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_outer_joins(parent, child) - join_type = Arel::Nodes::OuterJoin - make_join_constraints(parent, child, join_type, true) + def construct_tables!(join_root) + join_root.each_children do |parent, child| + child.tables = table_aliases_for(parent, child) + end end - def make_join_constraints(parent, child, join_type, aliasing = false) - tables = aliasing ? table_aliases_for(parent, child) : child.tables - joins = 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 - joins.concat child.children.flat_map { |c| make_join_constraints(child, c, join_type, aliasing) } + 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) @@ -168,13 +168,8 @@ module ActiveRecord } 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 = reflection.alias_candidate(parent.table_name) join ? "#{name}_join" : name end @@ -183,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) @@ -202,11 +197,11 @@ module ActiveRecord raise EagerLoadPolymorphicError.new(reflection) end - JoinAssociation.new(reflection, build(right, reflection.klass), alias_tracker) + 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| @@ -215,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 @@ -227,25 +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 && - node.reflection.scope_for(node.base_klass.unscoped).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] ||= @@ -259,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 c36386ec7e..ca0305abbb 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -1,22 +1,20 @@ # frozen_string_literal: true require "active_record/associations/join_dependency/join_part" +require "active_support/core_ext/array/extract" module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinAssociation < JoinPart # :nodoc: - # The reflection of the association represented - attr_reader :reflection + attr_reader :reflection, :tables + attr_accessor :table - attr_accessor :tables - - def initialize(reflection, children, alias_tracker) + def initialize(reflection, children) super(reflection.klass, children) - @alias_tracker = alias_tracker - @reflection = reflection - @tables = nil + @reflection = reflection + @tables = nil end def match?(other) @@ -24,27 +22,30 @@ module ActiveRecord super && reflection == other.reflection end - def join_constraints(foreign_table, foreign_klass, join_type, tables, chain) - joins = [] - 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 - constraint = reflection.build_join_constraint(table, foreign_table) - - joins << table.create_join(table, table.create_on(constraint), join_type) + join_scope = reflection.join_scope(table, foreign_table, foreign_klass) - join_scope = reflection.join_scope(table, foreign_klass) arel = join_scope.arel(alias_tracker.aliases) + nodes = arel.constraints.first + + others = nodes.children.extract! do |node| + Arel.fetch_attribute(node) { |attr| attr.relation.name != table.name } + end - if arel.constraints.any? + joins << table.create_join(table, table.create_on(nodes), join_type) + + unless others.empty? joins.concat arel.join_sources right = joins.last.right - right.expr = right.expr.and(arel.constraints) + right.expr.children.concat(others) end # The current table in this iteration becomes the foreign table in the next @@ -54,12 +55,16 @@ module ActiveRecord joins end - def table - tables.first + def tables=(tables) + @tables = tables + @table = tables.first end - private - attr_reader :alias_tracker + def readonly? + return @readonly if defined?(@readonly) + + @readonly = reflection.scope && reflection.scope_for(base_klass.unscoped).readonly_value + end 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 2181f308bf..3ad72a3646 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_part.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_part.rb @@ -33,6 +33,13 @@ module ActiveRecord children.each { |child| child.each(&block) } end + def each_children(&block) + children.each do |child| + yield self, child + child.each_children(&block) + end + end + # An Arel::Table for the active_record def table raise NotImplementedError @@ -47,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 1ea0aeac3a..6b57e5093a 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -88,7 +88,6 @@ module ActiveRecord if records.empty? [] else - records.uniq! Array.wrap(associations).flat_map { |association| preloaders_on association, records, preload_scope } @@ -98,34 +97,34 @@ 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) - when Symbol - preloaders_for_one(association, records, scope) - when String - preloaders_for_one(association.to_sym, records, scope) + preloaders_for_hash(association, records, scope, polymorphic_parent) + when Symbol, String + preloaders_for_one(association, 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 - - recs = loaders.flat_map(&:preloaded_records).uniq - loaders.concat Array.wrap(child).flat_map { |assoc| - preloaders_on assoc, recs, scope - } - loaders + grouped_records(parent, records, polymorphic_parent).flat_map do |reflection, reflection_records| + loaders = preloaders_for_reflection(reflection, reflection_records, scope) + recs = loaders.flat_map(&:preloaded_records) + child_polymorphic_parent = reflection && reflection.options[:polymorphic] + loaders.concat Array.wrap(child).flat_map { |assoc| + preloaders_on assoc, recs, scope, child_polymorphic_parent + } + loaders + end } end # Loads all the given data into +records+ for a singular +association+. # - # Functions by instantiating a preloader class such as Preloader::HasManyThrough and + # Functions by instantiating a preloader class such as Preloader::Association and # call the +run+ method for each passed in class in the +records+ argument. # # Not all records have the same class, so group then preload group on the reflection @@ -135,24 +134,25 @@ 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| - klasses.map do |rhs_klass, rs| - loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) - loader.run self - loader + def preloaders_for_one(association, records, scope, polymorphic_parent) + grouped_records(association, records, polymorphic_parent) + .flat_map do |reflection, reflection_records| + preloaders_for_reflection reflection, reflection_records, scope end + end + + def preloaders_for_reflection(reflection, records, scope) + records.group_by { |record| record.association(reflection.name).klass }.map do |rhs_klass, rs| + preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope).run end end - def grouped_records(association, records) + def grouped_records(association, records, polymorphic_parent) h = {} records.each do |record| - next unless record - assoc = record.association(association) - next unless assoc.klass - klasses = h[assoc.reflection] ||= {} - (klasses[assoc.klass] ||= []) << record + reflection = record.class._reflect_on_association(association) + next if polymorphic_parent && !reflection || !record.association(association).klass + (h[reflection] ||= []) << record end h end @@ -163,10 +163,18 @@ module ActiveRecord @reflection = reflection end - def run(preloader); end + def run + self + end def preloaded_records - owners.flat_map { |owner| owner.association(reflection.name).target } + @preloaded_records ||= records_by_owner.flat_map(&:last) + end + + def records_by_owner + @records_by_owner ||= owners.each_with_object({}) do |owner, result| + result[owner] = Array(owner.association(reflection.name).target) + end end private diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index d6f7359055..46532f651e 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -4,26 +4,44 @@ module ActiveRecord module Associations class Preloader class Association #:nodoc: - attr_reader :preloaded_records - def initialize(klass, owners, reflection, preload_scope) @klass = klass @owners = owners @reflection = reflection @preload_scope = preload_scope @model = owners.first && owners.first.class - @preloaded_records = [] end - def run(preloader) - records = load_records do |record| - owner = owners_by_key[convert_key(record[association_key_name])] - association = owner.association(reflection.name) - association.set_inverse_instance(record) + def run + if !preload_scope || preload_scope.empty_scope? + owners.each do |owner| + associate_records_to_owner(owner, records_by_owner[owner] || []) + end + else + # Custom preload scope is used and + # the association can not be marked as loaded + # Loading into a Hash instead + records_by_owner end + self + end - owners.each do |owner| - associate_records_to_owner(owner, records[convert_key(owner[owner_key_name])] || []) + def records_by_owner + @records_by_owner ||= preloaded_records.each_with_object({}) do |record, result| + owners_by_key[convert_key(record[association_key_name])].each do |owner| + (result[owner] ||= []) << record + end + end + end + + def preloaded_records + return @preloaded_records if defined?(@preloaded_records) + return [] if owner_keys.empty? + # Some databases impose a limit on the number of ids in a list (in Oracle it's 1000) + # Make several smaller queries if necessary or make one query if the adapter supports it + slices = owner_keys.each_slice(klass.connection.in_clause_length || owner_keys.size) + @preloaded_records = slices.flat_map do |slice| + records_for(slice) end end @@ -42,11 +60,10 @@ module ActiveRecord def associate_records_to_owner(owner, records) association = owner.association(reflection.name) - association.loaded! if reflection.collection? - association.target.concat(records) + association.target = records else - association.target = records.first unless records.empty? + association.target = records.first end end @@ -55,13 +72,10 @@ module ActiveRecord end def owners_by_key - unless defined?(@owners_by_key) - @owners_by_key = owners.each_with_object({}) do |owner, h| - key = convert_key(owner[owner_key_name]) - h[key] = owner if key - end + @owners_by_key ||= owners.each_with_object({}) do |owner, result| + key = convert_key(owner[owner_key_name]) + (result[key] ||= []) << owner if key end - @owners_by_key end def key_conversion_required? @@ -88,23 +102,16 @@ module ActiveRecord @model.type_for_attribute(owner_key_name).type end - def load_records(&block) - return {} if owner_keys.empty? - # Some databases impose a limit on the number of ids in a list (in Oracle it's 1000) - # Make several smaller queries if necessary or make one query if the adapter supports it - slices = owner_keys.each_slice(klass.connection.in_clause_length || owner_keys.size) - @preloaded_records = slices.flat_map do |slice| - records_for(slice, &block) - end - @preloaded_records.group_by do |record| - convert_key(record[association_key_name]) + def records_for(ids) + scope.where(association_key_name => ids).load do |record| + # Processing only the first owner + # because the record is modified but not an owner + owner = owners_by_key[convert_key(record[association_key_name])].first + association = owner.association(reflection.name) + association.set_inverse_instance(record) end end - def records_for(ids, &block) - scope.where(association_key_name => ids).load(&block) - end - def scope @scope ||= build_scope end @@ -116,7 +123,7 @@ module ActiveRecord def build_scope scope = klass.scope_for_association - if reflection.type + if reflection.type && !reflection.through_reflection? scope.where!(reflection.type => model.polymorphic_name) end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index a6b7ab80a2..bec1c4c94a 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -4,42 +4,57 @@ module ActiveRecord module Associations class Preloader class ThroughAssociation < Association # :nodoc: - def run(preloader) - already_loaded = owners.first.association(through_reflection.name).loaded? - through_scope = through_scope() - reflection_scope = target_reflection_scope - through_preloaders = preloader.preload(owners, through_reflection.name, through_scope) - middle_records = through_preloaders.flat_map(&:preloaded_records) - preloaders = preloader.preload(middle_records, source_reflection.name, reflection_scope) - @preloaded_records = preloaders.flat_map(&:preloaded_records) - - owners.each do |owner| - through_records = Array(owner.association(through_reflection.name).target) - if already_loaded + PRELOADER = ActiveRecord::Associations::Preloader.new + + def initialize(*) + super + @already_loaded = owners.first.association(through_reflection.name).loaded? + end + + def preloaded_records + @preloaded_records ||= source_preloaders.flat_map(&:preloaded_records) + end + + def records_by_owner + return @records_by_owner if defined?(@records_by_owner) + source_records_by_owner = source_preloaders.map(&:records_by_owner).reduce(:merge) + through_records_by_owner = through_preloaders.map(&:records_by_owner).reduce(:merge) + + @records_by_owner = owners.each_with_object({}) do |owner, result| + through_records = through_records_by_owner[owner] || [] + + if @already_loaded if source_type = reflection.options[:source_type] through_records = through_records.select do |record| record[reflection.foreign_type] == source_type end end - else - owner.association(through_reflection.name).reset if through_scope - end - result = through_records.flat_map do |record| - association = record.association(source_reflection.name) - target = association.target - association.reset if preload_scope - target end - result.compact! - if reflection_scope - result.sort_by! { |rhs| preload_index[rhs] } if reflection_scope.order_values.any? - result.uniq! if reflection_scope.distinct_value + + records = through_records.flat_map do |record| + source_records_by_owner[record] end - associate_records_to_owner(owner, result) + + records.compact! + records.sort_by! { |rhs| preload_index[rhs] } if scope.order_values.any? + records.uniq! if scope.distinct_value + result[owner] = records end end private + def source_preloaders + @source_preloaders ||= PRELOADER.preload(middle_records, source_reflection.name, scope) + end + + def middle_records + through_preloaders.flat_map(&:preloaded_records) + end + + def through_preloaders + @through_preloaders ||= PRELOADER.preload(owners, through_reflection.name, through_scope) + end + def through_reflection reflection.through_reflection end @@ -49,8 +64,8 @@ module ActiveRecord end def preload_index - @preload_index ||= @preloaded_records.each_with_object({}).with_index do |(id, result), index| - result[id] = index + @preload_index ||= preloaded_records.each_with_object({}).with_index do |(record, result), index| + result[record] = index end end @@ -58,11 +73,15 @@ module ActiveRecord scope = through_reflection.klass.unscoped options = reflection.options + values = reflection_scope.values + if annotations = values[:annotate] + scope.annotate!(*annotations) + end + if options[:source_type] scope.where! reflection.foreign_type => options[:source_type] elsif !reflection_scope.where_clause.empty? scope.where_clause = reflection_scope.where_clause - values = reflection_scope.values if includes = values[:includes] scope.includes!(source_reflection.name => includes) @@ -89,17 +108,7 @@ module ActiveRecord end end - scope unless scope.empty_scope? - end - - def target_reflection_scope - if preload_scope - reflection_scope.merge(preload_scope) - elsif reflection.scope - reflection_scope - else - nil - end + scope end end end diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb index 441bd715e4..a92932fa4b 100644 --- a/activerecord/lib/active_record/associations/singular_association.rb +++ b/activerecord/lib/active_record/associations/singular_association.rb @@ -17,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 @@ -27,7 +26,7 @@ module ActiveRecord # Implements the reload reader method, e.g. foo.reload_bar for # Foo.has_one :bar def force_reload_reader - klass.uncached { reload } + reload(true) target end @@ -37,21 +36,7 @@ module ActiveRecord end def find_target - scope = self.scope - return scope.take if skip_statement_cache?(scope) - - conn = klass.connection - 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, conn) do |record| - set_inverse_instance record - end.first - rescue ::RangeError - nil + super.first end def replace(record) @@ -62,13 +47,8 @@ module ActiveRecord replace(record) end - def _create_record(attributes, raise_error = false) - unless owner.persisted? - raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" - end - - 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 5afb0bc068..15e6565e69 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -114,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_assignment.rb b/activerecord/lib/active_record/attribute_assignment.rb index b6f0e18764..929045f29b 100644 --- a/activerecord/lib/active_record/attribute_assignment.rb +++ b/activerecord/lib/active_record/attribute_assignment.rb @@ -45,16 +45,14 @@ module ActiveRecord def execute_callstack_for_multiparameter_attributes(callstack) errors = [] callstack.each do |name, values_with_empty_parameters| - begin - if values_with_empty_parameters.each_value.all?(&:nil?) - values = nil - else - values = values_with_empty_parameters - end - send("#{name}=", values) - rescue => ex - errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) + if values_with_empty_parameters.each_value.all?(&:nil?) + values = nil + else + values = values_with_empty_parameters end + send("#{name}=", values) + rescue => ex + errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) end unless errors.empty? error_descriptions = errors.map(&:message).join(",") diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index 83b5a5e698..af7e46e649 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -22,18 +22,9 @@ module ActiveRecord delegate :column_for_attribute, to: :class end - AttrNames = Module.new { - def self.set_name_cache(name, value) - const_name = "ATTR_#{name}" - unless const_defined? const_name - const_set const_name, value.dup.freeze - end - end - } + RESTRICTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) - BLACKLISTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) - - class GeneratedAttributeMethods < Module #:nodoc: + class GeneratedAttributeMethodsBuilder < Module #:nodoc: include Mutex_m end @@ -44,7 +35,8 @@ module ActiveRecord end def initialize_generated_modules # :nodoc: - @generated_attribute_methods = GeneratedAttributeMethods.new + @generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethodsBuilder.new) + private_constant :GeneratedAttributeMethods @attribute_methods_generated = false include @generated_attribute_methods @@ -97,7 +89,7 @@ module ActiveRecord # If ThisClass < ... < SomeSuperClass < ... < Base and SomeSuperClass # defines its own attribute method, then we don't want to overwrite that. defined = method_defined_within?(method_name, superclass, Base) && - ! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethods) + ! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethodsBuilder) defined || super end end @@ -123,7 +115,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,12 +159,14 @@ module ActiveRecord end end - # Regexp whitelist. Matches the following: + # Regexp for column names (with or without a table name prefix). Matches + # the following: # "#{table_name}.#{column_name}" # "#{column_name}" - COLUMN_NAME_WHITELIST = /\A(?:\w+\.)?\w+\z/i + COLUMN_NAME = /\A(?:\w+\.)?\w+\z/i - # Regexp whitelist. Matches the following: + # 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" @@ -181,7 +175,7 @@ module ActiveRecord # "#{column_name} #{direction}" # "#{column_name} #{direction} NULLS FIRST" # "#{column_name} NULLS LAST" - COLUMN_NAME_ORDER_WHITELIST = / + COLUMN_NAME_WITH_ORDER = / \A (?:\w+\.)? \w+ @@ -190,12 +184,10 @@ module ActiveRecord \z /ix - def enforce_raw_sql_whitelist(args, whitelist: COLUMN_NAME_WHITELIST) # :nodoc: + 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| whitelist.match?(part) } + Arel.arel_node?(arg) || + arg.to_s.split(/\s*,\s*/).all? { |part| permit.match?(part) } end return if unexpected.none? @@ -270,21 +262,14 @@ module ActiveRecord def respond_to?(name, include_private = false) return false unless super - case name - when :to_partial_path - name = "to_partial_path".freeze - when :to_model - name = "to_model".freeze - else - name = name.to_s - end - # If the result is true then check for the select case. # For queries selecting a subset of columns, return false for unselected columns. # We check defined?(@attributes) not to issue warnings if called on objects that # have been allocated but not yet initialized. - if defined?(@attributes) && self.class.column_names.include?(name) - return has_attribute?(name) + if defined?(@attributes) + if name = self.class.symbol_column_to_string(name.to_sym) + return has_attribute?(name) + end end true @@ -344,15 +329,8 @@ module ActiveRecord # person.attribute_for_inspect(:tag_ids) # # => "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]" def attribute_for_inspect(attr_name) - value = read_attribute(attr_name) - - if value.is_a?(String) && value.length > 50 - "#{value[0, 50]}...".inspect - elsif value.is_a?(Date) || value.is_a?(Time) - %("#{value.to_s(:db)}") - else - value.inspect - end + value = _read_attribute(attr_name) + format_for_inspect(value) end # Returns +true+ if the specified +attribute+ has been set by the user or by a @@ -449,14 +427,6 @@ module ActiveRecord defined?(@attributes) && @attributes.key?(attr_name) end - def attributes_with_values_for_create(attribute_names) - attributes_with_values(attributes_for_create(attribute_names)) - end - - def attributes_with_values_for_update(attribute_names) - attributes_with_values(attributes_for_update(attribute_names)) - end - def attributes_with_values(attribute_names) attribute_names.each_with_object({}) do |name, attrs| attrs[name] = _read_attribute(name) @@ -465,7 +435,8 @@ module ActiveRecord # 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 @@ -473,11 +444,22 @@ 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 + def format_for_inspect(value) + if value.is_a?(String) && value.length > 50 + "#{value[0, 50]}...".inspect + elsif value.is_a?(Date) || value.is_a?(Time) + %("#{value.to_s(:db)}") + else + value.inspect + end + end + def readonly_attribute?(name) self.class.readonly_attributes.include?(name) end diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb index 7224f970e0..920a7b2038 100644 --- a/activerecord/lib/active_record/attribute_methods/dirty.rb +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -16,9 +16,6 @@ module ActiveRecord class_attribute :partial_writes, instance_writer: false, default: true - after_create { changes_applied } - after_update { changes_applied } - # Attribute methods for "changed in last call to save?" attribute_method_affix(prefix: "saved_change_to_", suffix: "?") attribute_method_prefix("saved_change_to_") @@ -32,18 +29,17 @@ module ActiveRecord # <tt>reload</tt> the record and clears changed attributes. def reload(*) super.tap do - @previously_changed = ActiveSupport::HashWithIndifferentAccess.new @mutations_before_last_save = nil - @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new @mutations_from_database = nil end end - # Did this attribute change when we last saved? This method can be invoked - # as +saved_change_to_name?+ instead of <tt>saved_change_to_attribute?("name")</tt>. - # 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 # @@ -53,28 +49,29 @@ module ActiveRecord # +to+ When passed, this method will return false unless the value was # changed to the given value def saved_change_to_attribute?(attr_name, **options) - mutations_before_last_save.changed?(attr_name, **options) + mutations_before_last_save.changed?(attr_name.to_s, options) end # Returns the change to an attribute during the last save. If the # 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 - # <tt>saved_change_to_attribute("name")</tt> + # 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) + mutations_before_last_save.change_to_attribute(attr_name.to_s) 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) + mutations_before_last_save.original_value(attr_name.to_s) end # Did the last call to +save+ have any changes to change? @@ -87,58 +84,102 @@ 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) + mutations_from_database.changed?(attr_name.to_s, 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) + mutations_from_database.change_to_attribute(attr_name.to_s) 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) + mutations_from_database.original_value(attr_name.to_s) 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 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) + mutations_from_database.changed_values end private - def write_attribute_without_type_cast(attr_name, _) - result = super - clear_attribute_change(attr_name) + def write_attribute_without_type_cast(attr_name, value) + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) + end + result = super(name, value) + clear_attribute_change(name) result end - def _update_record(*) - partial_writes? ? super(keys_for_partial_write) : super + def _update_record(attribute_names = attribute_names_for_partial_writes) + affected_rows = super + changes_applied + affected_rows end - def _create_record(*) - partial_writes? ? super(keys_for_partial_write) : super + def _create_record(attribute_names = attribute_names_for_partial_writes) + id = super + changes_applied + id end - def keys_for_partial_write - changed_attribute_names_to_save & self.class.column_names + 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 9b267bb7c0..6af5346fa7 100644 --- a/activerecord/lib/active_record/attribute_methods/primary_key.rb +++ b/activerecord/lib/active_record/attribute_methods/primary_key.rb @@ -14,38 +14,39 @@ module ActiveRecord [key] if key end - # Returns the primary key value. + # Returns the primary key column's value. def id sync_with_transaction_state primary_key = self.class.primary_key _read_attribute(primary_key) if primary_key end - # Sets the primary key value. + # Sets the primary key column's value. def id=(value) sync_with_transaction_state primary_key = self.class.primary_key _write_attribute(primary_key, value) if primary_key end - # Queries the primary key value. + # Queries the primary key column's value. def id? sync_with_transaction_state query_attribute(self.class.primary_key) end - # Returns the primary key value before type cast. + # Returns the primary key column's value before type cast. def id_before_type_cast sync_with_transaction_state read_attribute_before_type_cast(self.class.primary_key) end - # Returns the primary key previous value. + # Returns the primary key column's previous value. def id_was sync_with_transaction_state attribute_was(self.class.primary_key) end + # Returns the primary key column's value from the database. def id_in_database sync_with_transaction_state attribute_in_database(self.class.primary_key) diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb index 6757e9b66a..6811f54b10 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -16,8 +16,7 @@ module ActiveRecord when true then true when false, nil then false else - column = self.class.columns_hash[attr_name] - if column.nil? + if !type_for_attribute(attr_name) { false } if Numeric === value || value !~ /[^0-9]/ !value.to_i.zero? else diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index 14f700b6a9..ffac5313ad 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -8,42 +8,19 @@ module ActiveRecord module ClassMethods # :nodoc: private - # We want to generate the methods via module_eval rather than - # define_method, because define_method is slower on dispatch. - # Evaluating many similar methods may use more memory as the instruction - # sequences are duplicated and cached (in MRI). define_method may - # be slower on dispatch, but if you're careful about the closure - # created, then define_method will consume much less memory. - # - # But sometimes the database might return columns with - # characters that are not allowed in normal method names (like - # 'my_column(omg)'. So to work around this we first define with - # the __temp__ identifier, and then use alias method to rename - # it to what we want. - # - # We are also defining a constant to hold the frozen string of - # the attribute name. Using a constant means that we do not have - # to allocate an object on each call to the attribute method. - # 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.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 - STR - - generated_attribute_methods.module_eval do - alias_method name, temp_method - undef_method temp_method + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name} + #{sync_with_transaction_state} + name = #{attr_name_expr} + _read_attribute(name) { |n| missing_attribute(n, caller) } + end + RUBY end end end @@ -52,30 +29,21 @@ module ActiveRecord # it has been typecast (for example, "2004-12-12" in a date column is cast # to a date object, like Date.new(2004, 12, 12)). def read_attribute(attr_name, &block) - name = if self.class.attribute_alias?(attr_name) - self.class.attribute_alias(attr_name).to_s - else - attr_name.to_s + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) end primary_key = self.class.primary_key - name = primary_key if name == "id".freeze && primary_key + name = primary_key if name == "id" && primary_key sync_with_transaction_state if name == primary_key _read_attribute(name, &block) end # This method exists to avoid the expensive primary_key check internally, without # breaking compatibility with the read_attribute API - 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 - @attributes.fetch_value(attr_name.to_s, &block) - end - else - def _read_attribute(attr_name) # :nodoc: - @attributes.fetch_value(attr_name.to_s) { |n| yield n if block_given? } - end + def _read_attribute(attr_name, &block) # :nodoc + @attributes.fetch_value(attr_name.to_s, &block) end alias :attribute :_read_attribute 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 d2b7817b45..294a3dc32c 100644 --- a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -73,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 c7521422bb..455e67e19b 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -13,19 +13,19 @@ module ActiveRecord private def define_method_attribute=(name) - 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} - #{sync_with_transaction_state} - _write_attribute(name, value) - end - alias_method #{(name + '=').inspect}, :__temp__#{safe_name}= - undef_method :__temp__#{safe_name}= - STR + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name, writer: true, + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name}(value) + name = #{attr_name_expr} + #{sync_with_transaction_state} + _write_attribute(name, value) + end + RUBY + end end end @@ -33,14 +33,13 @@ module ActiveRecord # specified +value+. Empty strings for Integer and Float columns are # turned into +nil+. def write_attribute(attr_name, value) - name = if self.class.attribute_alias?(attr_name) - self.class.attribute_alias(attr_name).to_s - else - attr_name.to_s + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) end primary_key = self.class.primary_key - name = primary_key if name == "id".freeze && primary_key + name = primary_key if name == "id" && primary_key sync_with_transaction_state if name == primary_key _write_attribute(name, value) end diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index 35150889d9..7cf421c184 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -41,6 +41,9 @@ module ActiveRecord # +range+ (PostgreSQL only) specifies that the type should be a range (see the # examples below). # + # When using a symbol for +cast_type+, extra options are forwarded to the + # constructor of the type object. + # # ==== Examples # # The type detected by Active Record can be overridden. @@ -112,6 +115,16 @@ module ActiveRecord # my_float_range: 1.0..3.5 # } # + # Passing options to the type constructor + # + # # app/models/my_model.rb + # class MyModel < ActiveRecord::Base + # attribute :small_int, :integer, limit: 2 + # end + # + # MyModel.create(small_int: 65537) + # # => Error: 65537 is out of range for the limit of two bytes + # # ==== Creating Custom Types # # Users may also define their own custom types, as long as they respond diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index a1250c3835..50f29a81a6 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -149,7 +149,7 @@ module ActiveRecord private def define_non_cyclic_method(name, &block) - return if method_defined?(name) + return if instance_methods(false).include?(name) define_method(name) do |*args| result = true; @_already_called ||= {} # Loop prevention for validation of associations @@ -382,10 +382,14 @@ module ActiveRecord if association = association_instance_get(reflection.name) autosave = reflection.options[:autosave] + # By saving the instance variable in a local variable, + # we make the whole callback re-entrant. + new_record_before_save = @new_record_before_save + # reconstruct the scope now that we know the owner's id association.reset_scope - if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave) + if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave) if autosave records_to_destroy = records.select(&:marked_for_destruction?) records_to_destroy.each { |record| association.destroy(record) } @@ -397,11 +401,16 @@ module ActiveRecord saved = true - if autosave != false && (@new_record_before_save || record.new_record?) + if autosave != false && (new_record_before_save || record.new_record?) if autosave saved = association.insert_record(record, false) - 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) @@ -452,10 +461,16 @@ module ActiveRecord # If the record is new or it has changed, returns true. def record_changed?(reflection, record, key) record.new_record? || - (record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key) || + association_foreign_key_changed?(reflection, record, key) || record.will_save_change_to_attribute?(reflection.foreign_key) end + def association_foreign_key_changed?(reflection, record, key) + return false if reflection.through_reflection? + + record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key + end + # Saves the associated record if it's new or <tt>:autosave</tt> is enabled. # # In addition, it will destroy the association if it was marked for destruction. diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 7ab9160265..2af6d09b53 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -22,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,10 +288,9 @@ module ActiveRecord #:nodoc: extend Explain extend Enum extend Delegation::DelegateCache - extend CollectionCacheKey + extend Aggregations::ClassMethods include Core - include DatabaseConfigurations include Persistence include ReadonlyAttributes include ModelSchema @@ -314,7 +314,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 fd6819d08f..ef5444dfc3 100644 --- a/activerecord/lib/active_record/callbacks.rb +++ b/activerecord/lib/active_record/callbacks.rb @@ -95,7 +95,7 @@ module ActiveRecord # # private # def delete_parents - # self.class.delete_all "parent_id = #{id}" + # self.class.delete_by(parent_id: id) # end # end # @@ -128,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: # @@ -318,9 +318,13 @@ module ActiveRecord _run_touch_callbacks { super } end + def increment!(attribute, by = 1, touch: nil) # :nodoc: + touch ? _run_touch_callbacks { super } : super + end + private - def create_or_update(*) + def create_or_update(**) _run_save_callbacks { super } end @@ -328,7 +332,7 @@ module ActiveRecord _run_create_callbacks { super } end - def _update_record(*) + def _update_record _run_update_callbacks { super } end end diff --git a/activerecord/lib/active_record/collection_cache_key.rb b/activerecord/lib/active_record/collection_cache_key.rb deleted file mode 100644 index dfba78614e..0000000000 --- a/activerecord/lib/active_record/collection_cache_key.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module CollectionCacheKey - def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: - query_signature = ActiveSupport::Digest.hexdigest(collection.to_sql) - key = "#{collection.model_name.cache_key}/query-#{query_signature}" - - if collection.loaded? || collection.distinct_value - size = collection.records.size - if size > 0 - timestamp = collection.max_by(×tamp_column)._read_attribute(timestamp_column) - end - else - if collection.eager_loading? - collection = collection.send(:apply_join_dependency) - end - column_type = type_for_attribute(timestamp_column) - column = connection.column_name_from_arel_node(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 - - result = connection.select_one(arel, nil) - - if result.blank? - size = 0 - timestamp = nil - else - size = result["size"] - timestamp = column_type.deserialize(result["timestamp"]) - end - - end - - if timestamp - "#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" - else - "#{key}-#{size}" - end - end - end -end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index c730584902..68498b5dc5 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -185,14 +185,16 @@ module ActiveRecord def wait_poll(timeout) @num_waiting += 1 - t0 = Time.now + t0 = Concurrent.monotonic_time elapsed = 0 loop do - @cond.wait(timeout - elapsed) + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @cond.wait(timeout - elapsed) + end return remove if any? - elapsed = Time.now - t0 + elapsed = Concurrent.monotonic_time - t0 if elapsed >= timeout msg = "could not obtain a connection from the pool within %0.3f seconds (waited %0.3f seconds); all pooled connections were in use" % [timeout, elapsed] @@ -684,13 +686,13 @@ module ActiveRecord end newly_checked_out = [] - timeout_time = Time.now + (@checkout_timeout * 2) + timeout_time = Concurrent.monotonic_time + (@checkout_timeout * 2) @available.with_a_bias_for(Thread.current) do loop do synchronize do return if collected_conns.size == @connections.size && @now_connecting == 0 - remaining_timeout = timeout_time - Time.now + remaining_timeout = timeout_time - Concurrent.monotonic_time remaining_timeout = 0 if remaining_timeout < 0 conn = checkout_for_exclusive_access(remaining_timeout) collected_conns << conn @@ -729,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".dup + msg = +"could not obtain ownership of all database connections in #{checkout_timeout} seconds" thread_report = [] @connections.each do |conn| @@ -808,6 +810,7 @@ module ActiveRecord def new_connection Base.send(spec.adapter_method, spec.config).tap do |conn| conn.schema_cache = schema_cache.dup if schema_cache + conn.check_version end end @@ -913,6 +916,16 @@ module ActiveRecord # about the model. The model needs to pass a specification name to the handler, # in order to look up the correct connection pool. class ConnectionHandler + def self.create_owner_to_pool # :nodoc: + Concurrent::Map.new(initial_capacity: 2) do |h, k| + # Discard the parent's connection pools immediately; we have no need + # of them + discard_unowned_pools(h) + + h[k] = Concurrent::Map.new(initial_capacity: 2) + end + end + def self.unowned_pool_finalizer(pid_map) # :nodoc: lambda do |_| discard_unowned_pools(pid_map) @@ -927,13 +940,7 @@ module ActiveRecord def initialize # These caches are keyed by spec.name (ConnectionSpecification#name). - @owner_to_pool = Concurrent::Map.new(initial_capacity: 2) do |h, k| - # Discard the parent's connection pools immediately; we have no need - # of them - ConnectionHandler.discard_unowned_pools(h) - - h[k] = Concurrent::Map.new(initial_capacity: 2) - end + @owner_to_pool = ConnectionHandler.create_owner_to_pool # Backup finalizer: if the forked child never needed a pool, the above # early discard has not occurred @@ -1004,15 +1011,24 @@ module ActiveRecord # for (not necessarily the current class). def retrieve_connection(spec_name) #:nodoc: pool = retrieve_connection_pool(spec_name) - raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found." unless pool + + unless pool + # multiple database application + if ActiveRecord::Base.connection_handler != ActiveRecord::Base.default_connection_handler + raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found for the '#{ActiveRecord::Base.current_role}' role." + else + raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found." + end + end + 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 7a9e7add24..75e959045e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb @@ -1,22 +1,30 @@ # frozen_string_literal: true +require "active_support/deprecation" + module ActiveRecord module ConnectionAdapters # :nodoc: module DatabaseLimits + def max_identifier_length # :nodoc: + 64 + end + # Returns the maximum length of a table alias. def table_alias_length - 255 + max_identifier_length end # Returns the maximum length of a column name. def column_name_length - 64 + max_identifier_length end + deprecate :column_name_length # Returns the maximum length of a table name. def table_name_length - 64 + max_identifier_length end + deprecate :table_name_length # Returns the maximum allowed length for an index name. This # limit is enforced by \Rails and is less than or equal to @@ -29,23 +37,26 @@ module ActiveRecord # Returns the maximum length of an index name. def index_name_length - 64 + max_identifier_length end # Returns the maximum number of columns per table. 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. @@ -57,11 +68,18 @@ 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 + + private + def bind_params_length + 65535 + end 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 08f3e15a4b..ef19538447 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -20,9 +20,22 @@ module ActiveRecord raise "Passing bind parameters with an arel AST is forbidden. " \ "The values must be stored on the AST directly" end - sql, binds = visitor.accept(arel_or_sql_string.ast, collector).value - [sql.freeze, binds || []] + + if prepared_statements + sql, binds = visitor.compile(arel_or_sql_string.ast, collector) + + if binds.length > bind_params_length + unprepared_statement do + sql, binds = to_sql_and_binds(arel_or_sql_string) + visitor.preparable = false + end + end + else + sql = visitor.compile(arel_or_sql_string.ast, collector) + end + [sql.freeze, binds] else + visitor.preparable = false if prepared_statements [arel_or_sql_string.dup.freeze, binds] end end @@ -32,11 +45,11 @@ module ActiveRecord # can be used to query the database repeatedly. def cacheable_query(klass, arel) # :nodoc: if prepared_statements - sql, binds = visitor.accept(arel.ast, collector).value + sql, binds = visitor.compile(arel.ast, collector) query = klass.query(sql) else - collector = PartialQueryCollector.new - parts, binds = visitor.accept(arel.ast, collector).value + collector = klass.partial_query_collector + parts, binds = visitor.compile(arel.ast, collector) query = klass.partial_query(parts) end [query, binds] @@ -46,11 +59,11 @@ module ActiveRecord def select_all(arel, name = nil, binds = [], preparable: nil) 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 - preparable = visitor.preparable + + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false end + if prepared_statements && preparable select_prepared(sql, name, binds) else @@ -93,6 +106,11 @@ module ActiveRecord exec_query(sql, name).rows end + # Determines whether the SQL statement is a write query. + def write_query?(sql) + raise NotImplementedError + 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 @@ -113,7 +131,7 @@ module ActiveRecord # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil) - sql, binds = sql_for_insert(sql, pk, nil, sequence_name, binds) + sql, binds = sql_for_insert(sql, pk, binds) exec_query(sql, name, binds) end @@ -124,11 +142,6 @@ module ActiveRecord exec_query(sql, name, binds) end - # Executes the truncate statement. - def truncate(table_name, name = nil) - raise NotImplementedError - end - # Executes update +sql+ statement in the context of this connection using # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. @@ -163,12 +176,22 @@ module ActiveRecord exec_delete(sql, name, binds) end - # Returns +true+ when the connection adapter supports prepared statement - # caching, otherwise returns +false+ - def supports_statement_cache? # :nodoc: - true + # Executes the truncate statement. + def truncate(table_name, name = nil) + execute(build_truncate_statements(table_name), name) + end + + def truncate_tables(*table_names) # :nodoc: + return if table_names.empty? + + with_multi_statements do + disable_referential_integrity do + Array(build_truncate_statements(*table_names)).each do |sql| + execute_batch(sql, "Truncate Tables") + end + end + end end - deprecate :supports_statement_cache? # Runs the given block in a database transaction, and returns the result # of the block. @@ -259,7 +282,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? @@ -324,68 +349,30 @@ 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. + # Most of adapters should implement `insert_fixtures_set` 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 - - columns = schema_cache.columns_hash(table_name) - binds = fixture.map do |name, value| - if column = columns[name] - type = lookup_cast_type_from_column(column) - Relation::QueryAttribute.new(name, value, type) - else - raise Fixture::FixtureError, %(table "#{table_name}" has no column named #{name.inspect}.) - end - end - - table = Arel::Table.new(table_name) - - values = binds.map do |bind| - value = with_yaml_fallback(bind.value_for_database) - [table[bind.name], value] - end - - manager = Arel::InsertManager.new - manager.into(table) - manager.insert(values) - execute manager.to_sql, "Fixture Insert" - 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") + execute(build_fixture_sql(Array.wrap(fixture), table_name), "Fixture Insert") end def insert_fixtures_set(fixture_set, tables_to_delete = []) - fixture_inserts = fixture_set.map do |table_name, fixtures| - next if fixtures.empty? - - build_fixture_sql(fixtures, table_name) - end.compact - - table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name table}".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? + fixture_inserts = build_fixture_statements(fixture_set) + table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name(table)}" } + total_sql = Array(combine_multi_statements(table_deletes + fixture_inserts)) + + with_multi_statements do + disable_referential_integrity do + transaction(requires_new: true) do + total_sql.each do |sql| + execute_batch(sql, "Fixtures Load") + end end end end end - def empty_insert_statement_value + def empty_insert_statement_value(primary_key = nil) "DEFAULT VALUES" end @@ -403,25 +390,33 @@ module ActiveRecord end end - # The default strategy for an UPDATE with joins is to use a subquery. This doesn't work - # on MySQL (even when aliasing the tables), but MySQL allows using JOIN directly in - # an UPDATE statement, so in the MySQL adapters we redefine this to do that. - def join_to_update(update, select, key) # :nodoc: - subselect = subquery_for(key, select) - - update.where key.in(subselect) + # Fixture value is quoted by Arel, however scalar values + # are not quotable. In this case we want to convert + # the column value to YAML. + def with_yaml_fallback(value) # :nodoc: + if value.is_a?(Hash) || value.is_a?(Array) + YAML.dump(value) + else + value + end end - alias join_to_delete join_to_update private + def execute_batch(sql, name = nil) + execute(sql, name) + end + + DEFAULT_INSERT_VALUE = Arel.sql("DEFAULT").freeze + private_constant :DEFAULT_INSERT_VALUE + def default_insert_value(column) - Arel.sql("DEFAULT") + DEFAULT_INSERT_VALUE end def build_fixture_sql(fixtures, table_name) columns = schema_cache.columns_hash(table_name) - values = fixtures.map do |fixture| + values_list = fixtures.map do |fixture| fixture = fixture.stringify_keys unknown_columns = fixture.keys - columns.keys @@ -432,8 +427,7 @@ module ActiveRecord columns.map do |name, column| if fixture.key?(name) type = lookup_cast_type_from_column(column) - bind = Relation::QueryAttribute.new(name, fixture[name], type) - with_yaml_fallback(bind.value_for_database) + with_yaml_fallback(type.serialize(fixture[name])) else default_insert_value(column) end @@ -443,21 +437,45 @@ module ActiveRecord table = Arel::Table.new(table_name) manager = Arel::InsertManager.new manager.into(table) - columns.each_key { |column| manager.columns << table[column] } - manager.values = manager.create_values_list(values) + if values_list.size == 1 + values = values_list.shift + new_values = [] + columns.each_key.with_index { |column, i| + unless values[i].equal?(DEFAULT_INSERT_VALUE) + new_values << values[i] + manager.columns << table[column] + end + } + values_list << new_values + else + columns.each_key { |column| manager.columns << table[column] } + end + + manager.values = manager.create_values_list(values_list) manager.to_sql end - def combine_multi_statements(total_sql) - total_sql.join(";\n") + def build_fixture_statements(fixture_set) + fixture_set.map do |table_name, fixtures| + next if fixtures.empty? + build_fixture_sql(fixtures, table_name) + end.compact end - # Returns a subquery for the given key using the join information. - def subquery_for(key, select) - subselect = select.clone - subselect.projections = [key] - subselect + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "TRUNCATE TABLE #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + + def with_multi_statements + yield + end + + def combine_multi_statements(total_sql) + total_sql.join(";\n") end # Returns an ActiveRecord::Result instance. @@ -469,7 +487,7 @@ module ActiveRecord exec_query(sql, name, binds, prepare: true) end - def sql_for_insert(sql, pk, id_value, sequence_name, binds) + def sql_for_insert(sql, pk, binds) [sql, binds] end @@ -489,39 +507,6 @@ module ActiveRecord relation end end - - # Fixture value is quoted by Arel, however scalar values - # are not quotable. In this case we want to convert - # the column value to YAML. - def with_yaml_fallback(value) - if value.is_a?(Hash) || value.is_a?(Array) - YAML.dump(value) - else - value - end - end - - 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 - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 25622e34c8..a7753e3e9c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -7,7 +7,8 @@ module ActiveRecord module QueryCache class << self def included(base) #:nodoc: - dirties_query_cache base, :insert, :update, :delete, :rollback_to_savepoint, :rollback_db_transaction + dirties_query_cache base, :insert, :update, :delete, :truncate, :truncate_tables, + :rollback_to_savepoint, :rollback_db_transaction base.set_callback :checkout, :after, :configure_query_cache! base.set_callback :checkin, :after, :disable_query_cache! @@ -17,7 +18,7 @@ module ActiveRecord method_names.each do |method_name| base.class_eval <<-end_code, __FILE__, __LINE__ + 1 def #{method_name}(*) - clear_query_cache if @query_cache_enabled + ActiveRecord::Base.clear_query_caches_for_current_thread if @query_cache_enabled super end end_code @@ -96,6 +97,11 @@ module ActiveRecord if @query_cache_enabled && !locked?(arel) arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) + + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false + end + cache_sql(sql, name, binds) { super(sql, name, binds, preparable: preparable) } else super @@ -110,12 +116,7 @@ module ActiveRecord if @query_cache[sql].key?(binds) ActiveSupport::Notifications.instrument( "sql.active_record", - sql: sql, - binds: binds, - type_casted_binds: -> { type_casted_binds(binds) }, - name: name, - connection_id: object_id, - cached: true, + cache_notification_info(sql, name, binds) ) @query_cache[sql][binds] else @@ -125,6 +126,19 @@ 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) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb index aec5fa6ba1..2877530917 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb @@ -60,7 +60,7 @@ module ActiveRecord # Quotes a string, escaping any ' (single quote) and \ (backslash) # characters. def quote_string(s) - s.gsub('\\'.freeze, '\&\&'.freeze).gsub("'".freeze, "''".freeze) # ' (for ruby-mode) + s.gsub('\\', '\&\&').gsub("'", "''") # ' (for ruby-mode) end # Quotes the column name. Defaults to no quoting. @@ -95,7 +95,7 @@ module ActiveRecord end def quoted_true - "TRUE".freeze + "TRUE" end def unquoted_true @@ -103,7 +103,7 @@ module ActiveRecord end def quoted_false - "FALSE".freeze + "FALSE" end def unquoted_false @@ -130,6 +130,7 @@ module ActiveRecord end def quoted_time(value) # :nodoc: + value = value.change(year: 2000, month: 1, day: 1) quoted_date(value).sub(/\A\d\d\d\d-\d\d-\d\d /, "") end @@ -137,15 +138,19 @@ module ActiveRecord "'#{quote_string(value.to_s)}'" end - def type_casted_binds(binds) # :nodoc: - if binds.first.is_a?(Array) - binds.map { |column, value| type_cast(value, column) } - else - binds.map { |attr| type_cast(attr.value_for_database) } - end + def sanitize_as_sql_comment(value) # :nodoc: + value.to_s.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "") end private + def type_casted_binds(binds) + if binds.first.is_a?(Array) + binds.map { |column, value| type_cast(value, column) } + else + binds.map { |attr| type_cast(attr.value_for_database) } + end + end + def lookup_cast_type(sql_type) type_map.lookup(sql_type) end @@ -156,13 +161,9 @@ module ActiveRecord end end - def types_which_need_no_typecasting - [nil, Numeric, String] - end - def _quote(value) case value - when String, ActiveSupport::Multibyte::Chars + when String, Symbol, ActiveSupport::Multibyte::Chars "'#{quote_string(value.to_s)}'" when true then quoted_true when false then quoted_false @@ -173,7 +174,6 @@ module ActiveRecord when Type::Binary::Data then quoted_binary(value) when Type::Time::Value then "'#{quoted_time(value)}'" when Date, Time then "'#{quoted_date(value)}'" - when Symbol then "'#{quote_string(value.to_s)}'" when Class then "'#{value}'" else raise TypeError, "can't quote #{value.class.name}" end @@ -187,10 +187,9 @@ module ActiveRecord when false then unquoted_false # BigDecimals need to be put in a non-normalized form and quoted. when BigDecimal then value.to_s("F") + when nil, Numeric, String then value when Type::Time::Value then quoted_time(value) when Date, Time then quoted_date(value) - when *types_which_need_no_typecasting - value else raise TypeError end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb index 529c9d8ca6..7d20825a75 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb @@ -15,13 +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, + :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys?, :foreign_key_options, to: :@conn, private: true private def visit_AlterTable(o) - sql = "ALTER TABLE #{quote_table_name(o.name)} ".dup + sql = +"ALTER TABLE #{quote_table_name(o.name)} " 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(" ") @@ -29,17 +29,19 @@ 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}".dup + column_sql = +"#{quote_column_name(o.name)} #{o.sql_type}" add_column_options!(column_sql, column_options(o)) unless o.type == :primary_key column_sql end def visit_AddColumnDefinition(o) - "ADD #{accept(o.column)}".dup + +"ADD #{accept(o.column)}" end def visit_TableDefinition(o) - create_sql = "CREATE#{' TEMPORARY' if o.temporary} TABLE #{quote_table_name(o.name)} ".dup + create_sql = +"CREATE#{table_modifier_in_create(o)} TABLE " + create_sql << "IF NOT EXISTS " if o.if_not_exists + create_sql << "#{quote_table_name(o.name)} " statements = o.columns.map { |c| accept c } statements << accept(o.primary_keys) if o.primary_keys @@ -48,7 +50,7 @@ module ActiveRecord statements.concat(o.indexes.map { |column_name, options| index_in_create(o.name, column_name, options) }) end - if supports_foreign_keys_in_create? + if supports_foreign_keys? statements.concat(o.foreign_keys.map { |to_table, options| foreign_key_in_create(o.name, to_table, options) }) end @@ -119,7 +121,15 @@ module ActiveRecord sql end + # Returns any SQL string to go between CREATE and TABLE. May be nil. + def table_modifier_in_create(o) + " TEMPORARY" if o.temporary + end + def foreign_key_in_create(from_table, to_table, options) + prefix = ActiveRecord::Base.table_name_prefix + suffix = ActiveRecord::Base.table_name_suffix + to_table = "#{prefix}#{to_table}#{suffix}" options = foreign_key_options(from_table, to_table, options) accept ForeignKeyDefinition.new(from_table, to_table, options) end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 6a498b353c..688eea75e8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -102,16 +102,12 @@ module ActiveRecord alias validated? validate? def export_name_on_schema_dump? - name !~ ActiveRecord::SchemaDumper.fk_ignore_pattern + !ActiveRecord::SchemaDumper.fk_ignore_pattern.match?(name) if name end - def defined_for?(to_table_ord = nil, to_table: nil, **options) - if to_table_ord - self.to_table == to_table_ord.to_s - else - (to_table.nil? || to_table.to_s == self.to_table) && - options.all? { |k, v| self.options[k].to_s == v.to_s } - end + def defined_for?(to_table: nil, **options) + (to_table.nil? || to_table.to_s == self.to_table) && + options.all? { |k, v| self.options[k].to_s == v.to_s } end private @@ -198,41 +194,44 @@ module ActiveRecord end module ColumnMethods + extend ActiveSupport::Concern + # Appends a primary key definition to the table definition. # Can be called multiple times, but this is probably not a good idea. def primary_key(name, type = :primary_key, **options) column(name, type, options.merge(primary_key: true)) end + ## + # :method: column + # :call-seq: column(name, type, **options) + # # Appends a column or columns of a specified type. # # t.string(:goat) # t.string(:goat, :sheep) # # See TableDefinition#column - [ - :bigint, - :binary, - :boolean, - :date, - :datetime, - :decimal, - :float, - :integer, - :json, - :string, - :text, - :time, - :timestamp, - :virtual, - ].each do |column_type| - module_eval <<-CODE, __FILE__, __LINE__ + 1 - def #{column_type}(*args, **options) - args.each { |name| column(name, :#{column_type}, options) } + + included do + define_column_methods :bigint, :binary, :boolean, :date, :datetime, :decimal, + :float, :integer, :json, :string, :text, :time, :timestamp, :virtual + + alias :numeric :decimal + end + + class_methods do + private def define_column_methods(*column_types) # :nodoc: + column_types.each do |column_type| + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{column_type}(*names, **options) + raise ArgumentError, "Missing column name(s) for #{column_type}" if names.empty? + names.each { |name| column(name, :#{column_type}, options) } + end + RUBY end - CODE + end end - alias_method :numeric, :decimal end # Represents the schema of an SQL table in an abstract way. This class @@ -256,15 +255,25 @@ module ActiveRecord class TableDefinition include ColumnMethods - attr_accessor :indexes - attr_reader :name, :temporary, :options, :as, :foreign_keys, :comment + attr_reader :name, :temporary, :if_not_exists, :options, :as, :comment, :indexes, :foreign_keys - def initialize(name, temporary = false, options = nil, as = nil, comment: nil) + def initialize( + conn, + name, + temporary: false, + if_not_exists: false, + options: nil, + as: nil, + comment: nil, + ** + ) + @conn = conn @columns_hash = {} @indexes = [] @foreign_keys = [] @primary_keys = nil @temporary = temporary + @if_not_exists = if_not_exists @options = options @as = as @name = name @@ -348,16 +357,20 @@ module ActiveRecord # # create_table :taggings do |t| # t.references :tag, index: { name: 'index_taggings_on_tag_id' } - # t.references :tagger, polymorphic: true, index: true - # t.references :taggable, polymorphic: { default: 'Photo' } + # t.references :tagger, polymorphic: true + # t.references :taggable, polymorphic: { default: 'Photo' }, index: false # end - def column(name, type, options = {}) + def column(name, type, **options) name = name.to_s type = type.to_sym if type options = options.dup - 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) @@ -381,10 +394,7 @@ module ActiveRecord end def foreign_key(table_name, options = {}) # :nodoc: - table_name_prefix = ActiveRecord::Base.table_name_prefix - table_name_suffix = ActiveRecord::Base.table_name_suffix - table_name = "#{table_name_prefix}#{table_name}#{table_name_suffix}" - foreign_keys.push([table_name, options]) + foreign_keys << [table_name, options] end # Appends <tt>:datetime</tt> columns <tt>:created_at</tt> and @@ -394,6 +404,10 @@ module ActiveRecord def timestamps(**options) options[:null] = false if options[:null].nil? + if !options.key?(:precision) && @conn.supports_datetime_with_precision? + options[:precision] = 6 + end + column(:created_at, :datetime, options) column(:updated_at, :datetime, options) end @@ -402,6 +416,7 @@ module ActiveRecord # # t.references(:user) # t.belongs_to(:supplier, foreign_key: true) + # t.belongs_to(:supplier, foreign_key: true, type: :integer) # # See {connection.add_reference}[rdoc-ref:SchemaStatements#add_reference] for details of the options you can use. def references(*args, **options) @@ -497,7 +512,11 @@ module ActiveRecord # t.date # t.binary # t.boolean + # t.foreign_key + # t.json + # t.virtual # t.remove + # t.remove_foreign_key # t.remove_references # t.remove_belongs_to # t.remove_index @@ -519,8 +538,10 @@ module ActiveRecord # t.column(:name, :string) # # See TableDefinition#column for details of the options you can use. - def column(column_name, type, options = {}) + def column(column_name, type, **options) + index_options = options.delete(:index) @base.add_column(name, column_name, type, options) + index(column_name, index_options.is_a?(Hash) ? index_options : {}) if index_options end # Checks to see if a column exists. @@ -659,21 +680,32 @@ module ActiveRecord end alias :remove_belongs_to :remove_references - # Adds a foreign key. + # Adds a foreign key to the table using a supplied table name. # - # t.foreign_key(:authors) + # t.foreign_key(:authors) + # t.foreign_key(:authors, column: :author_id, primary_key: "id") # # 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 + # Removes the given foreign key from the table. + # + # t.remove_foreign_key(:authors) + # t.remove_foreign_key(column: :author_id) + # + # See {connection.remove_foreign_key}[rdoc-ref:SchemaStatements#remove_foreign_key] + def remove_foreign_key(*args) + @base.remove_foreign_key(name, *args) + end + # Checks to see if a foreign key exists. # - # t.foreign_key(:authors) unless t.foreign_key_exists?(:authors) + # 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_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index ac73337aef..4840307094 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -2,6 +2,7 @@ require "active_record/migration/join_table" require "active_support/core_ext/string/access" +require "active_support/deprecation" require "digest/sha2" module ActiveRecord @@ -129,11 +130,11 @@ module ActiveRecord # column_exists?(:suppliers, :name, :string, null: false) # column_exists?(:suppliers, :tax, :decimal, precision: 8, scale: 2) # - def column_exists?(table_name, column_name, type = nil, options = {}) + def column_exists?(table_name, column_name, type = nil, **options) column_name = column_name.to_s checks = [] checks << lambda { |c| c.name == column_name } - checks << lambda { |c| c.type == type } if type + checks << lambda { |c| c.type == type.to_sym rescue nil } if type column_options_keys.each do |attr| checks << lambda { |c| c.send(attr) == options[attr] } if options.key?(attr) end @@ -205,19 +206,22 @@ module ActiveRecord # Set to true to drop the table before creating it. # Set to +:cascade+ to drop dependent objects as well. # Defaults to false. + # [<tt>:if_not_exists</tt>] + # Set to true to avoid raising an error when the table already exists. + # Defaults to false. # [<tt>:as</tt>] # SQL to use to generate the table. When this option is used, the block is # ignored, as are the <tt>:id</tt> and <tt>:primary_key</tt> options. # # ====== Add a backend specific option to the generated SQL (MySQL) # - # create_table(:suppliers, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8') + # create_table(:suppliers, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4') # # generates: # # CREATE TABLE suppliers ( # id bigint auto_increment PRIMARY KEY - # ) ENGINE=InnoDB DEFAULT CHARSET=utf8 + # ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 # # ====== Rename the primary key column # @@ -287,8 +291,8 @@ module ActiveRecord # SELECT * FROM orders INNER JOIN line_items ON order_id=orders.id # # See also TableDefinition#column for details on how to create columns. - def create_table(table_name, comment: nil, **options) - td = create_table_definition table_name, options[:temporary], options[:options], options[:as], comment: comment + def create_table(table_name, **options) + td = create_table_definition(table_name, options) if options[:id] != false && !options[:as] pk = options.fetch(:primary_key) do @@ -305,8 +309,7 @@ module ActiveRecord yield td if block_given? if options[:force] - drop_opts = { if_exists: true }.merge(**options) - drop_table(table_name, drop_opts) + drop_table(table_name, options.merge(if_exists: true)) end result = execute schema_creation.accept td @@ -318,7 +321,9 @@ module ActiveRecord end if supports_comments? && !supports_comments_in_create? - change_table_comment(table_name, comment) if comment.present? + if table_comment = options[:comment].presence + change_table_comment(table_name, table_comment) + end td.columns.each do |column| change_column_comment(table_name, column.name, column.comment) if column.comment.present? @@ -523,6 +528,9 @@ module ActiveRecord # 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. # @@ -576,7 +584,7 @@ module ActiveRecord # # Defines a column with a database-specific type. # add_column(:shapes, :triangle, 'polygon') # # ALTER TABLE "shapes" ADD "triangle" polygon - def add_column(table_name, column_name, type, options = {}) + def add_column(table_name, column_name, type, **options) at = create_alter_table table_name at.add_column(column_name, type, options) execute schema_creation.accept at @@ -600,6 +608,7 @@ 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)} #{remove_column_for_alter(table_name, column_name, type, options)}" end @@ -742,22 +751,13 @@ module ActiveRecord # ====== Creating an index with a specific operator class # # add_index(:developers, :name, using: 'gist', opclass: :gist_trgm_ops) - # - # generates: - # - # CREATE INDEX developers_on_name ON developers USING gist (name gist_trgm_ops) -- PostgreSQL + # # 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 }) - # - # generates: - # - # CREATE INDEX developers_on_name_and_city ON developers USING gist (name, city gist_trgm_ops) -- PostgreSQL + # # 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) - # - # generates: - # - # CREATE INDEX developers_on_name_and_city ON developers USING gist (name gist_trgm_ops, city gist_trgm_ops) -- PostgreSQL + # # 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 # @@ -852,17 +852,17 @@ module ActiveRecord # [<tt>:null</tt>] # Whether the column allows nulls. Defaults to true. # - # ====== Create a user_id bigint column + # ====== Create a user_id bigint column without an index # - # add_reference(:products, :user) + # add_reference(:products, :user, index: false) # # ====== Create a user_id string column # # add_reference(:products, :user, type: :string) # - # ====== Create supplier_id, supplier_type columns and appropriate index + # ====== Create supplier_id, supplier_type columns # - # add_reference(:products, :supplier, polymorphic: true, index: true) + # add_reference(:products, :supplier, polymorphic: true) # # ====== Create a supplier_id column with a unique index # @@ -890,7 +890,7 @@ module ActiveRecord # # ====== Remove the reference # - # remove_reference(:products, :user, index: true) + # remove_reference(:products, :user, index: false) # # ====== Remove polymorphic reference # @@ -898,7 +898,7 @@ module ActiveRecord # # ====== Remove the reference with a foreign key # - # remove_reference(:products, :user, index: true, foreign_key: true) + # remove_reference(:products, :user, foreign_key: true) # def remove_reference(table_name, ref_name, foreign_key: false, polymorphic: false, **options) if foreign_key @@ -966,7 +966,7 @@ module ActiveRecord # [<tt>:on_update</tt>] # Action that happens <tt>ON UPDATE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+ # [<tt>:validate</tt>] - # (Postgres only) Specify whether or not the constraint should be validated. Defaults to +true+. + # (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+. def add_foreign_key(from_table, to_table, options = {}) return unless supports_foreign_keys? @@ -990,15 +990,22 @@ 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. - def remove_foreign_key(from_table, options_or_to_table = {}) + # 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, to_table = nil, **options) return unless supports_foreign_keys? - fk_name_to_delete = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_delete = foreign_key_for!(from_table, to_table: to_table, **options).name at = create_alter_table from_table at.drop_foreign_key fk_name_to_delete @@ -1017,14 +1024,12 @@ module ActiveRecord # # Checks to see if a foreign key with a custom name exists. # foreign_key_exists?(:accounts, name: "special_fk_name") # - def foreign_key_exists?(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table).present? + def foreign_key_exists?(from_table, to_table = nil, **options) + foreign_key_for(from_table, to_table: to_table, **options).present? end def foreign_key_column_for(table_name) # :nodoc: - prefix = Base.table_name_prefix - suffix = Base.table_name_suffix - name = table_name.to_s =~ /#{prefix}(.+)#{suffix}/ ? $1 : table_name.to_s + name = strip_table_name_prefix_and_suffix(table_name) "#{name.singularize}_id" end @@ -1044,15 +1049,18 @@ module ActiveRecord { primary_key: true } end - def assume_migrated_upto_version(version, migrations_paths) - migrations_paths = Array(migrations_paths) + def assume_migrated_upto_version(version, migrations_paths = nil) + unless migrations_paths.nil? + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Passing migrations_paths to #assume_migrated_upto_version is deprecated and will be removed in Rails 6.1. + MSG + end + version = version.to_i sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) - 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 + migrated = migration_context.get_all_versions + versions = migration_context.migrations.map(&:version) unless migrated.include?(version) execute "INSERT INTO #{sm_table} (version) VALUES (#{quote(version)})" @@ -1063,13 +1071,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 @@ -1095,7 +1097,7 @@ module ActiveRecord if (0..6) === precision column_type_sql << "(#{precision})" else - raise(ActiveRecordError, "No #{native[:name]} type has precision of #{precision}. The allowed range of precision is from 0 to 6") + raise ArgumentError, "No #{native[:name]} type has precision of #{precision}. The allowed range of precision is from 0 to 6" end elsif (type != :primary_key) && (limit ||= native.is_a?(Hash) && native[:limit]) column_type_sql << "(#{limit})" @@ -1125,6 +1127,10 @@ module ActiveRecord def add_timestamps(table_name, options = {}) options[:null] = false if options[:null].nil? + if !options.key?(:precision) && supports_datetime_with_precision? + options[:precision] = 6 + end + add_column table_name, :created_at, :datetime, options add_column table_name, :updated_at, :datetime, options end @@ -1286,7 +1292,7 @@ module ActiveRecord end def create_table_definition(*args) - TableDefinition.new(*args) + TableDefinition.new(self, *args) end def create_alter_table(name) @@ -1320,6 +1326,12 @@ module ActiveRecord { column: column_names } end + def strip_table_name_prefix_and_suffix(table_name) + prefix = Base.table_name_prefix + suffix = Base.table_name_suffix + table_name.to_s =~ /#{prefix}(.+)#{suffix}/ ? $1 : table_name.to_s + end + def foreign_key_name(table_name, options) options.fetch(:name) do identifier = "#{table_name}_#{options.fetch(:column)}_fk" @@ -1329,14 +1341,14 @@ module ActiveRecord end end - def foreign_key_for(from_table, options_or_to_table = {}) + def foreign_key_for(from_table, **options) return unless supports_foreign_keys? - foreign_keys(from_table).detect { |fk| fk.defined_for? options_or_to_table } + foreign_keys(from_table).detect { |fk| fk.defined_for?(options) } end - def foreign_key_for!(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table) || \ - raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options_or_to_table}") + def foreign_key_for!(from_table, to_table: nil, **options) + foreign_key_for(from_table, to_table: to_table, **options) || + raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") end def extract_foreign_key_action(specifier) @@ -1385,7 +1397,7 @@ module ActiveRecord sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) if versions.is_a?(Array) - sql = "INSERT INTO #{sm_table} (version) VALUES\n".dup + sql = +"INSERT INTO #{sm_table} (version) VALUES\n" sql << versions.map { |v| "(#{quote(v)})" }.join(",\n") sql << ";\n\n" sql diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index d9ac8db6a8..c9e84e48cc 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -17,11 +17,19 @@ 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? @@ -32,33 +40,24 @@ module ActiveRecord committed? || rolledback? end - def set_state(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 - 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 @@ -74,13 +73,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 @@ -89,8 +89,12 @@ module ActiveRecord records << record end - def rollback - @state.rollback! + def materialize! + @materialized = true + end + + def materialized? + @materialized end def rollback_records @@ -104,10 +108,6 @@ module ActiveRecord end end - def commit - @state.commit! - end - def before_commit_records records.uniq.each(&:before_committed!) if @run_commit_callbacks end @@ -119,7 +119,7 @@ module ActiveRecord record.committed! else # if not running callbacks, only adds the record to the parent transaction - record.add_to_transaction + connection.add_transaction_record(record) end end ensure @@ -133,48 +133,55 @@ module ActiveRecord end class SavepointTransaction < Transaction - def initialize(connection, savepoint_name, parent_transaction, options, *args) - super(connection, options, *args) + def initialize(connection, savepoint_name, parent_transaction, *args) + super(connection, *args) parent_transaction.state.add_child(@state) - if options[:isolation] + 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 @@ -182,6 +189,9 @@ module ActiveRecord def initialize(connection) @stack = [] @connection = connection + @has_unmaterialized_transactions = false + @materializing_transactions = false + @lazy_transactions_enabled = true end def begin_transaction(options = {}) @@ -195,11 +205,44 @@ module ActiveRecord run_commit_callbacks: run_commit_callbacks) end + if @connection.supports_lazy_transactions? && lazy_transactions_enabled? && options[:_lazy] != false + @has_unmaterialized_transactions = true + else + transaction.materialize! + end @stack.push(transaction) transaction end end + 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 + + @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 @@ -225,26 +268,24 @@ module ActiveRecord def within_new_transaction(options = {}) @connection.lock.synchronize do - begin - transaction = begin_transaction options - yield - rescue Exception => error - if transaction + transaction = begin_transaction options + yield + rescue Exception => error + if transaction + rollback_transaction + after_failure_actions(transaction, error) + end + raise + ensure + if !error && transaction + if Thread.current.status == "aborting" 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 + else + begin + commit_transaction + rescue Exception + rollback_transaction(transaction) unless transaction.state.completed? + raise 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 559f068c39..200184c2f9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -6,6 +6,7 @@ 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 "active_support/deprecation" require "arel/collectors/bind" require "arel/collectors/composite" require "arel/collectors/sql_string" @@ -65,7 +66,7 @@ module ActiveRecord # Most of the methods in the adapter are useful during migrations. Most # notably, the instance methods provided by SchemaStatements are very useful. class AbstractAdapter - ADAPTER_NAME = "Abstract".freeze + ADAPTER_NAME = "Abstract" include ActiveSupport::Callbacks define_callbacks :checkout, :checkin @@ -76,12 +77,16 @@ module ActiveRecord SIMPLE_INT = /\A\d+\z/ - attr_accessor :visitor, :pool - attr_reader :schema_cache, :owner, :logger, :prepared_statements, :lock + attr_accessor :pool + attr_reader :schema_cache, :visitor, :owner, :logger, :lock, :prepared_statements, :prevent_writes 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 @@ -96,6 +101,11 @@ module ActiveRecord end end + def self.build_read_query_regexp(*parts) # :nodoc: + parts = parts.map { |part| /\A[\(\s]*#{part}/i } + Regexp.union(*parts) + end + def initialize(connection, logger = nil, config = {}) # :nodoc: super() @@ -108,7 +118,9 @@ module ActiveRecord @idle_since = Concurrent.monotonic_time @schema_cache = SchemaCache.new self @quoted_column_names, @quoted_table_names = {}, {} + @prevent_writes = false @visitor = arel_visitor + @statements = build_statement_pool @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) @@ -117,6 +129,34 @@ 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 + + # Determines whether writes are currently being prevents. + # + # Returns true if the connection is a replica, or if +prevent_writes+ + # is set to true. + def preventing_writes? + replica? || prevent_writes + end + + # Prevent writing to the database regardless of role. + # + # In some cases you may want to prevent writes to the database + # even if you are on a database that can write. `while_preventing_writes` + # will prevent writes to the database for the duration of the block. + def while_preventing_writes + original, @prevent_writes = @prevent_writes, true + yield + ensure + @prevent_writes = original end def migrations_paths # :nodoc: @@ -137,6 +177,10 @@ module ActiveRecord def <=>(version_string) @version <=> version_string.split(".").map(&:to_i) end + + def to_s + @version.join(".") + end end def valid_type?(type) # :nodoc: @@ -146,7 +190,7 @@ module ActiveRecord # this method must only be called while holding connection pool's mutex def lease if in_use? - msg = "Cannot lease connection, ".dup + msg = +"Cannot lease connection, " if @owner == Thread.current msg << "it is already leased by the current thread." else @@ -290,12 +334,18 @@ module ActiveRecord def supports_foreign_keys_in_create? supports_foreign_keys? end + deprecate :supports_foreign_keys_in_create? # Does this adapter support views? def supports_views? false end + # Does this adapter support materialized views? + def supports_materialized_views? + false + end + # Does this adapter support datetime with precision? def supports_datetime_with_precision? false @@ -320,6 +370,7 @@ module ActiveRecord def supports_multi_insert? true end + deprecate :supports_multi_insert? # Does this adapter support virtual columns? def supports_virtual_columns? @@ -331,6 +382,31 @@ module ActiveRecord false end + # Does this adapter support optimizer hints? + def supports_optimizer_hints? + false + end + + def supports_lazy_transactions? + false + end + + def supports_insert_returning? + false + end + + def supports_insert_on_duplicate_skip? + false + end + + def supports_insert_on_duplicate_update? + false + end + + def supports_insert_conflict_target? + false + end + # This is meant to be implemented by the adapters that support extensions def disable_extension(name) end @@ -339,6 +415,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 # @@ -416,11 +496,9 @@ module ActiveRecord # this should be overridden by concrete adapters end - ### - # Clear any caching the database adapter may be doing, for example - # clearing the prepared statement cache. This is database specific. + # Clear any caching the database adapter may be doing. def clear_cache! - # this should be overridden by concrete adapters + @lock.synchronize { @statements.clear } if @statements end # Returns true if its required to reload the connection between requests for development mode. @@ -442,18 +520,25 @@ module ActiveRecord # 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 - def case_sensitive_comparison(table, attribute, column, value) # :nodoc: - table[attribute].eq(value) + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + attribute.eq(value) + end + + def case_sensitive_comparison(attribute, value) # :nodoc: + attribute.eq(value) end - def case_insensitive_comparison(table, attribute, column, value) # :nodoc: + def case_insensitive_comparison(attribute, value) # :nodoc: + column = column_for_attribute(attribute) + if can_perform_case_insensitive_comparison_for?(column) - table[attribute].lower.eq(table.lower(value)) + attribute.lower.eq(attribute.relation.lower(value)) else - table[attribute].eq(value) + attribute.eq(value) end end @@ -468,18 +553,38 @@ module ActiveRecord end def column_name_for_operation(operation, node) # :nodoc: - column_name_from_arel_node(node) - end - - def column_name_from_arel_node(node) # :nodoc: - visitor.accept(node, Arel::Collectors::SQLString.new).value + visitor.compile(node) end def default_index_type?(index) # :nodoc: index.using.nil? end + # Called by ActiveRecord::InsertAll, + # Passed an instance of ActiveRecord::InsertAll::Builder, + # This method implements standard bulk inserts for all databases, but + # should be overridden by adapters to implement common features with + # non-standard syntax like handling duplicates or returning values. + def build_insert_sql(insert) # :nodoc: + if insert.skip_duplicates? || insert.update_duplicates? + raise NotImplementedError, "#{self.class} should define `build_insert_sql` to implement adapter-specific logic for handling duplicates during INSERT" + end + + "INSERT #{insert.into} #{insert.values_list}" + end + + def get_database_version # :nodoc: + end + + def database_version # :nodoc: + schema_cache.database_version + end + + def check_version # :nodoc: + end + private + def type_map @type_map ||= Type::TypeMap.new.tap do |mapping| initialize_type_map(mapping) @@ -553,14 +658,12 @@ module ActiveRecord $1.to_i if sql_type =~ /\((.*)\)/ end - def translate_exception_class(e, sql) - begin - message = "#{e.class.name}: #{e.message}: #{sql}" - rescue Encoding::CompatibilityError - message = "#{e.class.name}: #{e.message.force_encoding sql.encoding}: #{sql}" - end + def translate_exception_class(e, sql, binds) + message = "#{e.class.name}: #{e.message}" - exception = translate_exception(e, message) + exception = translate_exception( + e, message: message, sql: sql, binds: binds + ) exception.set_backtrace e.backtrace exception end @@ -573,24 +676,23 @@ module ActiveRecord binds: binds, 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) + connection_id: object_id, + connection: self) do + @lock.synchronize do + yield end + rescue => e + raise translate_exception_class(e, sql, binds) end end - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) # override in derived class case exception when RuntimeError exception else - ActiveRecord::StatementInvalid.new(message) + ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds) end end @@ -604,6 +706,11 @@ module ActiveRecord raise(ActiveRecordError, "No such column: #{table_name}.#{column_name}") end + def column_for_attribute(attribute) + table_name = attribute.relation.name + schema_cache.columns_hash(table_name)[attribute.name.to_s] + end + def collector if prepared_statements Arel::Collectors::Composite.new( @@ -621,6 +728,9 @@ module ActiveRecord def arel_visitor Arel::Visitors::ToSql.new(self) end + + def build_statement_pool + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index fbedddd7f9..ca8bbc14da 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -29,7 +29,7 @@ module ActiveRecord NATIVE_DATABASE_TYPES = { primary_key: "bigint auto_increment PRIMARY KEY", string: { name: "varchar", limit: 255 }, - text: { name: "text", limit: 65535 }, + text: { name: "text" }, integer: { name: "int", limit: 4 }, float: { name: "float", limit: 24 }, decimal: { name: "decimal" }, @@ -37,29 +37,26 @@ module ActiveRecord timestamp: { name: "timestamp" }, time: { name: "time" }, date: { name: "date" }, - binary: { name: "blob", limit: 65535 }, + binary: { name: "blob" }, + blob: { name: "blob" }, boolean: { name: "tinyint", limit: 1 }, json: { name: "json" }, } class StatementPool < ConnectionAdapters::StatementPool # :nodoc: - private def dealloc(stmt) - stmt[:stmt].close - end + private + + def dealloc(stmt) + stmt.close + end end def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) - - if version < "5.1.10" - raise "Your version of MySQL (#{version_string}) is too old. Active Record supports MySQL >= 5.1.10." - end end - def version #:nodoc: - @version ||= Version.new(version_string) + def get_database_version #:nodoc: + Version.new(version_string) end def mariadb? # :nodoc: @@ -71,7 +68,11 @@ module ActiveRecord end def supports_index_sort_order? - !mariadb? && version >= "8.0.1" + !mariadb? && database_version >= "8.0.1" + end + + def supports_expression_index? + !mariadb? && database_version >= "8.0.13" end def supports_transaction_isolation? @@ -95,25 +96,30 @@ module ActiveRecord end def supports_datetime_with_precision? - if mariadb? - version >= "5.3.0" - else - version >= "5.6.4" - end + mariadb? || database_version >= "5.6.4" end def supports_virtual_columns? - if mariadb? - version >= "5.2.0" - else - version >= "5.7.5" - end + mariadb? || database_version >= "5.7.5" + end + + # See https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html for more details. + def supports_optimizer_hints? + !mariadb? && database_version >= "5.7.7" end def supports_advisory_locks? true end + def supports_insert_on_duplicate_skip? + true + end + + def supports_insert_on_duplicate_update? + true + end + def get_advisory_lock(lock_name, timeout = 0) # :nodoc: query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1 end @@ -127,7 +133,7 @@ module ActiveRecord end def index_algorithms - { default: "ALGORITHM = DEFAULT".dup, copy: "ALGORITHM = COPY".dup, inplace: "ALGORITHM = INPLACE".dup } + { default: +"ALGORITHM = DEFAULT", copy: +"ALGORITHM = COPY", inplace: +"ALGORITHM = INPLACE" } end # HELPER METHODS =========================================== @@ -159,10 +165,9 @@ module ActiveRecord # CONNECTION MANAGEMENT ==================================== - # Clears the prepared statements cache. - def clear_cache! + def clear_cache! # :nodoc: reload_type_map - @statements.clear + super end #-- @@ -171,15 +176,17 @@ module ActiveRecord def explain(arel, binds = []) sql = "EXPLAIN #{to_sql(arel, binds)}" - start = Time.now + start = Concurrent.monotonic_time result = exec_query(sql, "EXPLAIN", binds) - elapsed = Time.now - start + elapsed = Concurrent.monotonic_time - start MySQL::ExplainPrettyPrinter.new.pp(result, elapsed) end # 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) @@ -211,19 +218,7 @@ module ActiveRecord execute "ROLLBACK" end - # In the simple case, MySQL allows us to place JOINs directly into the UPDATE - # query. However, this does not allow for LIMIT, OFFSET and ORDER. To support - # these, we must use a subquery. - def join_to_update(update, select, key) # :nodoc: - if select.limit || select.offset || select.orders.any? - super - else - update.table select.source - update.wheres = select.constraints - end - end - - def empty_insert_statement_value + def empty_insert_statement_value(primary_key = nil) "VALUES ()" end @@ -239,7 +234,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' @@ -248,8 +243,12 @@ module ActiveRecord def create_database(name, options = {}) if options[:collation] execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT COLLATE #{quote_table_name(options[:collation])}" + elsif options[:charset] + execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset])}" + elsif row_format_dynamic_by_default? + execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET `utf8mb4`" else - execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset] || 'utf8')}" + raise "Configure a supported :charset and ensure innodb_large_prefix is enabled to support indexes on varchar(255) string columns." end end @@ -275,10 +274,6 @@ module ActiveRecord show_variable "collation_database" end - def truncate(table_name, name = nil) - execute "TRUNCATE TABLE #{quote_table_name(table_name)}", name - end - def table_comment(table_name) # :nodoc: scope = quoted_scope(table_name) @@ -376,7 +371,7 @@ module ActiveRecord 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}".dup + sql = +"CREATE #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} ON #{quote_table_name(table_name)} (#{index_columns}) #{index_algorithm}" execute add_sql_comment!(sql, comment) end @@ -442,30 +437,6 @@ module ActiveRecord table_options end - # Maps logical Rails types to MySQL-specific data types. - def type_to_sql(type, limit: nil, precision: nil, scale: nil, unsigned: nil, **) # :nodoc: - sql = \ - case type.to_s - when "integer" - integer_to_sql(limit) - when "text" - text_to_sql(limit) - when "blob" - binary_to_sql(limit) - when "binary" - if (0..0xfff) === limit - "varbinary(#{limit})" - else - binary_to_sql(limit) - end - else - super - end - - sql = "#{sql} unsigned" if unsigned && type != :primary_key - sql - end - # SHOW VARIABLES LIKE 'name' def show_variable(name) query_value("SELECT @@#{name}", "SCHEMA") @@ -488,9 +459,26 @@ module ActiveRecord SQL end - def case_sensitive_comparison(table, attribute, column, value) # :nodoc: + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + column = column_for_attribute(attribute) + + if column.collation && !column.case_sensitive? && !value.nil? + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. + To continue case sensitive comparison on the :#{attribute.name} attribute in #{klass} model, + pass `case_sensitive: true` option explicitly to the uniqueness validator. + MSG + attribute.eq(Arel::Nodes::Bin.new(value)) + else + super + end + end + + def case_sensitive_comparison(attribute, value) # :nodoc: + column = column_for_attribute(attribute) + if column.collation && !column.case_sensitive? - table[attribute].eq(Arel::Nodes::Bin.new(value)) + attribute.eq(Arel::Nodes::Bin.new(value)) else super end @@ -524,50 +512,27 @@ module ActiveRecord index.using == :btree || super end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - with_multi_statements do - super { discard_remaining_results } - end - end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" - private - def 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 + if insert.skip_duplicates? + no_op_column = quote_column_name(insert.keys.first) + sql << " ON DUPLICATE KEY UPDATE #{no_op_column}=#{no_op_column}" + elsif insert.update_duplicates? + sql << " ON DUPLICATE KEY UPDATE " + sql << insert.updatable_columns.map { |column| "#{column}=VALUES(#{column})" }.join(",") end - def 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 + sql + end - def max_allowed_packet - bytes_margin = 2 - @max_allowed_packet ||= (show_variable("max_allowed_packet") - bytes_margin) + def check_version # :nodoc: + if database_version < "5.5.8" + raise "Your version of MySQL (#{database_version}) is too old. Active Record supports MySQL >= 5.5.8." end + end - def with_multi_statements - previous_flags = @config[:flags] - @config[:flags] = Mysql2::Client::MULTI_STATEMENTS - reconnect! - - yield - ensure - @config[:flags] = previous_flags - reconnect! - end + private def initialize_type_map(m = type_map) super @@ -596,13 +561,13 @@ module ActiveRecord m.alias_type %r(bit)i, "binary" m.register_type(%r(enum)i) do |sql_type| - limit = sql_type[/^enum\((.+)\)/i, 1] + limit = sql_type[/^enum\s*\((.+)\)/i, 1] .split(",").map { |enum| enum.strip.length - 2 }.max MysqlString.new(limit: limit) end m.register_type(%r(^set)i) do |sql_type| - limit = sql_type[/^set\((.+)\)/i, 1] + limit = sql_type[/^set\s*\((.+)\)/i, 1] .split(",").map { |set| set.strip.length - 1 }.sum - 1 MysqlString.new(limit: limit) end @@ -629,7 +594,10 @@ module ActiveRecord # See https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html ER_DUP_ENTRY = 1062 ER_NOT_NULL_VIOLATION = 1048 + ER_NO_REFERENCED_ROW = 1216 + ER_ROW_IS_REFERENCED = 1217 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 @@ -639,35 +607,36 @@ module ActiveRecord ER_LOCK_WAIT_TIMEOUT = 1205 ER_QUERY_INTERRUPTED = 1317 ER_QUERY_TIMEOUT = 3024 + ER_FK_INCOMPATIBLE_COLUMNS = 3780 - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) case error_number(exception) when ER_DUP_ENTRY - RecordNotUnique.new(message) - when ER_NO_REFERENCED_ROW_2 - InvalidForeignKey.new(message) - when ER_CANNOT_ADD_FOREIGN - mismatched_foreign_key(message) + RecordNotUnique.new(message, sql: sql, binds: binds) + when ER_NO_REFERENCED_ROW, ER_ROW_IS_REFERENCED, ER_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2 + InvalidForeignKey.new(message, sql: sql, binds: binds) + when ER_CANNOT_ADD_FOREIGN, ER_FK_INCOMPATIBLE_COLUMNS + mismatched_foreign_key(message, sql: sql, binds: binds) when ER_CANNOT_CREATE_TABLE if message.include?("errno: 150") - mismatched_foreign_key(message) + mismatched_foreign_key(message, sql: sql, binds: binds) else super end when ER_DATA_TOO_LONG - ValueTooLong.new(message) + ValueTooLong.new(message, sql: sql, binds: binds) when ER_OUT_OF_RANGE - RangeError.new(message) + RangeError.new(message, sql: sql, binds: binds) when ER_NOT_NULL_VIOLATION, ER_DO_NOT_HAVE_DEFAULT - NotNullViolation.new(message) + NotNullViolation.new(message, sql: sql, binds: binds) when ER_LOCK_DEADLOCK - Deadlocked.new(message) + Deadlocked.new(message, sql: sql, binds: binds) when ER_LOCK_WAIT_TIMEOUT - LockWaitTimeout.new(message) + LockWaitTimeout.new(message, sql: sql, binds: binds) when ER_QUERY_TIMEOUT - StatementTimeout.new(message) + StatementTimeout.new(message, sql: sql, binds: binds) when ER_QUERY_INTERRUPTED - QueryCanceled.new(message) + QueryCanceled.new(message, sql: sql, binds: binds) else super end @@ -720,6 +689,12 @@ module ActiveRecord end def add_timestamps_for_alter(table_name, options = {}) + options[:null] = false if options[:null].nil? + + if !options.key?(:precision) && supports_datetime_with_precision? + options[:precision] = 6 + end + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] end @@ -727,22 +702,8 @@ module ActiveRecord [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) - 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' - subselect.distinct unless select.limit || select.offset || select.orders.any? - - 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? - mariadb? ? false : version >= "5.7.6" + mariadb? ? false : database_version >= "5.7.6" end def configure_connection @@ -779,7 +740,7 @@ module ActiveRecord # 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]}".dup + encoding = +"NAMES #{@config[:encoding]}" encoding << " COLLATE #{@config[:collation]}" if @config[:collation] encoding << ", " end @@ -812,47 +773,32 @@ module ActiveRecord Arel::Visitors::MySQL.new(self) end - def mismatched_foreign_key(message) - parts = message.scan(/`(\w+)`[ $)]/).flatten - MismatchedForeignKey.new( - self, - message: message, - table: parts[0], - foreign_key: parts[1], - target_table: parts[2], - primary_key: parts[3], - ) + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) end - def integer_to_sql(limit) # :nodoc: - case limit - when 1; "tinyint" - when 2; "smallint" - when 3; "mediumint" - when nil, 4; "int" - when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead.") - end - end + def mismatched_foreign_key(message, sql:, binds:) + match = %r/ + (?:CREATE|ALTER)\s+TABLE\s*(?:`?\w+`?\.)?`?(?<table>\w+)`?.+? + FOREIGN\s+KEY\s*\(`?(?<foreign_key>\w+)`?\)\s* + REFERENCES\s*(`?(?<target_table>\w+)`?)\s*\(`?(?<primary_key>\w+)`?\) + /xmi.match(sql) - def text_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinytext" - when nil, 0x100..0xffff; "text" - when 0x10000..0xffffff; "mediumtext" - when 0x1000000..0xffffffff; "longtext" - else raise(ActiveRecordError, "No text type has byte length #{limit}") - end - end + options = { + message: message, + sql: sql, + binds: binds, + } - def binary_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinyblob" - when nil, 0x100..0xffff; "blob" - when 0x10000..0xffffff; "mediumblob" - when 0x1000000..0xffffffff; "longblob" - else raise(ActiveRecordError, "No binary type has byte length #{limit}") + if match + options[:table] = match[:table] + options[:foreign_key] = match[:foreign_key] + options[:target_table] = match[:target_table] + options[:primary_key] = match[:primary_key] + options[:primary_key_column] = column_for(match[:target_table], match[:primary_key]) end + + MismatchedForeignKey.new(options) end def version_string diff --git a/activerecord/lib/active_record/connection_adapters/column.rb b/activerecord/lib/active_record/connection_adapters/column.rb index 5d81de9fe1..279d0b9e84 100644 --- a/activerecord/lib/active_record/connection_adapters/column.rb +++ b/activerecord/lib/active_record/connection_adapters/column.rb @@ -5,7 +5,7 @@ module ActiveRecord module ConnectionAdapters # An abstract definition of a column in a table. class Column - attr_reader :name, :default, :sql_type_metadata, :null, :table_name, :default_function, :collation, :comment + attr_reader :name, :default, :sql_type_metadata, :null, :default_function, :collation, :comment delegate :precision, :scale, :limit, :type, :sql_type, to: :sql_type_metadata, allow_nil: true @@ -15,9 +15,8 @@ module ActiveRecord # +default+ is the type-casted default value, such as +new+ in <tt>sales_stage varchar(20) default 'new'</tt>. # +sql_type_metadata+ is various information about the type of the column # +null+ determines if this column allows +NULL+ values. - def initialize(name, default, sql_type_metadata = nil, null = true, table_name = nil, default_function = nil, collation = nil, comment: nil, **) + def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation: nil, comment: nil, **) @name = name.freeze - @table_name = table_name @sql_type_metadata = sql_type_metadata @null = null @default = default @@ -44,7 +43,6 @@ module ActiveRecord def init_with(coder) @name = coder["name"] - @table_name = coder["table_name"] @sql_type_metadata = coder["sql_type_metadata"] @null = coder["null"] @default = coder["default"] @@ -55,7 +53,6 @@ module ActiveRecord def encode_with(coder) coder["name"] = @name - coder["table_name"] = @table_name coder["sql_type_metadata"] = @sql_type_metadata coder["null"] = @null coder["default"] = @default @@ -66,19 +63,26 @@ module ActiveRecord def ==(other) other.is_a?(Column) && - attributes_for_hash == other.attributes_for_hash + name == other.name && + default == other.default && + sql_type_metadata == other.sql_type_metadata && + null == other.null && + default_function == other.default_function && + collation == other.collation && + comment == other.comment end alias :eql? :== def hash - attributes_for_hash.hash + Column.hash ^ + name.hash ^ + default.hash ^ + sql_type_metadata.hash ^ + null.hash ^ + default_function.hash ^ + collation.hash ^ + comment.hash end - - protected - - def attributes_for_hash - [self.class, name, default, sql_type_metadata, null, table_name, default_function, collation] - end end class NullColumn < Column diff --git a/activerecord/lib/active_record/connection_adapters/connection_specification.rb b/activerecord/lib/active_record/connection_adapters/connection_specification.rb index 901717ae3d..9eaf9d9a89 100644 --- a/activerecord/lib/active_record/connection_adapters/connection_specification.rb +++ b/activerecord/lib/active_record/connection_adapters/connection_specification.rb @@ -57,9 +57,7 @@ module ActiveRecord private - def uri - @uri - end + attr_reader :uri def uri_parser @uri_parser ||= URI::Parser.new @@ -116,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 @@ -138,33 +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.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. # @@ -178,7 +156,9 @@ 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) @@ -194,12 +174,12 @@ module ActiveRecord 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 + raise LoadError, "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 + raise LoadError, "Error loading the '#{spec[:adapter]}' Active Record adapter. Missing a gem it depends on? #{e.message}", e.backtrace end end @@ -213,7 +193,6 @@ module ActiveRecord end private - # Returns fully resolved connection, accepts hash, string or symbol. # Always returns a hash. # @@ -234,32 +213,64 @@ 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, <<~MSG + The `#{env_name}` database is not configured for the `#{ActiveRecord::ConnectionHandling::DEFAULT_ENV.call}` environment. + + Available databases configurations are: + + #{build_configuration_sentence} + MSG end end + def build_configuration_sentence # :nodoc: + configs = configurations.configs_for(include_replicas: true) + + configs.group_by(&:env_name).map do |env, config| + namespaces = config.map(&:spec_name) + if namespaces.size > 1 + "#{env}: #{namespaces.join(", ")}" + else + env + end + end.join("\n") + end + # Accepts a hash. Expands the "url" key that contains a # URL database connection to a full connection # hash and merges with the rest of the hash. 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 3dcb916d99..1df4dea2d8 100644 --- a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb +++ b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb @@ -3,14 +3,19 @@ module ActiveRecord module ConnectionAdapters module DetermineIfPreparableVisitor - attr_reader :preparable + attr_accessor :preparable def accept(*) @preparable = true super end - def visit_Arel_Nodes_In(*) + def visit_Arel_Nodes_In(o, collector) + @preparable = false + super + end + + def visit_Arel_Nodes_NotIn(o, collector) @preparable = false super end 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 458c9bfd70..2132e5d248 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -11,7 +11,7 @@ module ActiveRecord else super end - discard_remaining_results + @connection.abandon_results! result end @@ -19,8 +19,19 @@ module ActiveRecord execute(sql, name).to_a end + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :set, :show, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + # Executes the SQL statement in the context of this connection. def execute(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been # made since we established the connection @connection.query_options[:database_timezone] = ActiveRecord::Base.default_timezone @@ -31,18 +42,28 @@ module ActiveRecord def exec_query(sql, name = "SQL", binds = [], prepare: false) if without_prepared_statement?(binds) execute_and_free(sql, name) do |result| - ActiveRecord::Result.new(result.fields, result.to_a) if result + if result + ActiveRecord::Result.new(result.fields, result.to_a) + else + ActiveRecord::Result.new([], []) + end end else exec_stmt_and_free(sql, name, binds, cache_stmt: prepare) do |_, result| - ActiveRecord::Result.new(result.fields, result.to_a) if result + if result + ActiveRecord::Result.new(result.fields, result.to_a) + else + ActiveRecord::Result.new([], []) + end end end end def exec_delete(sql, name = nil, binds = []) if without_prepared_statement?(binds) - execute_and_free(sql, name) { @connection.affected_rows } + @lock.synchronize do + execute_and_free(sql, name) { @connection.affected_rows } + end else exec_stmt_and_free(sql, name, binds) { |stmt| stmt.affected_rows } end @@ -50,19 +71,97 @@ module ActiveRecord alias :exec_update :exec_delete private + def execute_batch(sql, name = nil) + super + @connection.abandon_results! + end + def default_insert_value(column) - Arel.sql("DEFAULT") unless column.auto_increment? + super unless column.auto_increment? end def last_inserted_id(result) @connection.last_id end - def discard_remaining_results - @connection.abandon_results! + def supports_set_server_option? + @connection.respond_to?(:set_server_option) + end + + def build_truncate_statements(*table_names) + if table_names.size == 1 + super.first + else + super + end + end + + def multi_statements_enabled?(flags) + if flags.is_a?(Array) + flags.include?("MULTI_STATEMENTS") + else + (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 + + def combine_multi_statements(total_sql) + total_sql.each_with_object([]) do |sql, total_sql_chunks| + previous_packet = total_sql_chunks.last + if max_allowed_packet_reached?(sql, previous_packet) + total_sql_chunks << +sql + else + previous_packet << ";\n" + previous_packet << sql + end + end + end + + def max_allowed_packet_reached?(current_packet, previous_packet) + if current_packet.bytesize > max_allowed_packet + raise ActiveRecordError, + "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." + elsif previous_packet.nil? + true + else + (current_packet.bytesize + previous_packet.bytesize + 2) > max_allowed_packet + end + end + + def max_allowed_packet + @max_allowed_packet ||= show_variable("max_allowed_packet") end def exec_stmt_and_free(sql, name, binds, cache_stmt: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been # made since we established the connection @connection.query_options[:database_timezone] = ActiveRecord::Base.default_timezone @@ -71,10 +170,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/quoting.rb b/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb index be038403b8..75564a61d6 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb @@ -5,7 +5,7 @@ module ActiveRecord module MySQL module Quoting # :nodoc: def quote_column_name(name) - @quoted_column_names[name] ||= "`#{super.gsub('`', '``')}`".freeze + @quoted_column_names[name] ||= "`#{super.gsub('`', '``')}`" end def quote_table_name(name) 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 c9ea653b77..82ed320617 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb @@ -17,7 +17,7 @@ module ActiveRecord end def visit_ChangeColumnDefinition(o) - change_column_sql = "CHANGE #{quote_column_name(o.name)} #{accept(o.column)}".dup + change_column_sql = +"CHANGE #{quote_column_name(o.name)} #{accept(o.column)}" add_column_position!(change_column_sql, column_options(o.column)) end @@ -64,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})".dup, comment) + add_sql_comment!((+"#{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})"), 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 2ed4ad16ae..d21535a709 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -4,48 +4,56 @@ module ActiveRecord module ConnectionAdapters module MySQL module ColumnMethods - def blob(*args, **options) - args.each { |name| column(name, :blob, options) } - end + extend ActiveSupport::Concern - def tinyblob(*args, **options) - args.each { |name| column(name, :tinyblob, options) } - end + ## + # :method: blob + # :call-seq: blob(*names, **options) - def mediumblob(*args, **options) - args.each { |name| column(name, :mediumblob, options) } - end + ## + # :method: tinyblob + # :call-seq: tinyblob(*names, **options) - def longblob(*args, **options) - args.each { |name| column(name, :longblob, options) } - end + ## + # :method: mediumblob + # :call-seq: mediumblob(*names, **options) - def tinytext(*args, **options) - args.each { |name| column(name, :tinytext, options) } - end + ## + # :method: longblob + # :call-seq: longblob(*names, **options) - def mediumtext(*args, **options) - args.each { |name| column(name, :mediumtext, options) } - end + ## + # :method: tinytext + # :call-seq: tinytext(*names, **options) - def longtext(*args, **options) - args.each { |name| column(name, :longtext, options) } - end + ## + # :method: mediumtext + # :call-seq: mediumtext(*names, **options) - def unsigned_integer(*args, **options) - args.each { |name| column(name, :unsigned_integer, options) } - end + ## + # :method: longtext + # :call-seq: longtext(*names, **options) - def unsigned_bigint(*args, **options) - args.each { |name| column(name, :unsigned_bigint, options) } - end + ## + # :method: unsigned_integer + # :call-seq: unsigned_integer(*names, **options) - def unsigned_float(*args, **options) - args.each { |name| column(name, :unsigned_float, options) } - end + ## + # :method: unsigned_bigint + # :call-seq: unsigned_bigint(*names, **options) + + ## + # :method: unsigned_float + # :call-seq: unsigned_float(*names, **options) + + ## + # :method: unsigned_decimal + # :call-seq: unsigned_decimal(*names, **options) - def unsigned_decimal(*args, **options) - args.each { |name| column(name, :unsigned_decimal, options) } + included do + define_column_methods :blob, :tinyblob, :mediumblob, :longblob, + :tinytext, :mediumtext, :longtext, :unsigned_integer, :unsigned_bigint, + :unsigned_float, :unsigned_decimal end end 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 d23178e43c..234fb25fdf 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb @@ -10,6 +10,10 @@ module ActiveRecord spec[:unsigned] = "true" if column.unsigned? spec[:auto_increment] = "true" if column.auto_increment? + if /\A(?<size>tiny|medium|long)(?:text|blob)/ =~ column.sql_type + spec = { size: size.to_sym.inspect }.merge!(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) @@ -37,19 +41,21 @@ module ActiveRecord case column.sql_type when /\Atimestamp\b/ :timestamp - when "tinyblob" - :blob else super end end + def schema_limit(column) + super unless /\A(?:tiny|medium|long)?(?:text|blob)/.match?(column.sql_type) + end + def schema_precision(column) super unless /\A(?:date)?time(?:stamp)?\b/.match?(column.sql_type) && column.precision == 0 end def schema_collation(column) - if column.collation && table_name = column.table_name + if column.collation @table_collation_cache ||= {} @table_collation_cache[table_name] ||= @connection.exec_query("SHOW TABLE STATUS LIKE #{@connection.quote(table_name)}", "SCHEMA").first["Collation"] @@ -58,14 +64,14 @@ module ActiveRecord end def extract_expression_for_virtual_column(column) - if @connection.mariadb? && @connection.version < "10.2.5" - create_table_info = @connection.send(:create_table_info, column.table_name) + if @connection.mariadb? && @connection.database_version < "10.2.5" + create_table_info = @connection.send(:create_table_info, table_name) column_name = @connection.quote_column_name(column.name) if %r/#{column_name} #{Regexp.quote(column.sql_type)}(?: COLLATE \w+)? AS \((?<expression>.+?)\) #{column.extra}/ =~ create_table_info $~[:expression].inspect end else - scope = @connection.send(:quoted_scope, column.table_name) + scope = @connection.send(:quoted_scope, table_name) column_name = @connection.quote(column.name) sql = "SELECT generation_expression FROM information_schema.columns" \ " WHERE table_schema = #{scope[:schema]}" \ diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb index ce50590651..25a1fb234a 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -35,13 +35,39 @@ module ActiveRecord ] end - indexes.last[-2] << row[:Column_name] - indexes.last[-1][:lengths].merge!(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" + if row[:Expression] + expression = row[:Expression] + expression = +"(#{expression})" unless expression.start_with?("(") + indexes.last[-2] << expression + indexes.last[-1][:expressions] ||= {} + indexes.last[-1][:expressions][expression] = expression + indexes.last[-1][:orders][expression] = :desc if row[:Collation] == "D" + else + 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][row[:Column_name]] = :desc if row[:Collation] == "D" + end end end - indexes.map { |index| IndexDefinition.new(*index) } + indexes.map do |index| + options = index.last + + if expressions = options.delete(:expressions) + orders = options.delete(:orders) + lengths = options.delete(:lengths) + + columns = index[-2].map { |name| + [ name.to_sym, expressions[name] || +quote_column_name(name) ] + }.to_h + + index[-2] = add_options_for_index_columns( + columns, order: orders, length: lengths + ).values.join(", ") + end + + IndexDefinition.new(*index) + end end def remove_column(table_name, column_name, type = nil, options = {}) @@ -51,9 +77,13 @@ module ActiveRecord super end + def create_table(table_name, options: default_row_format, **) + 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") + if !row_format_dynamic_by_default? && CHARSETS_OF_4BYTES_MAXLEN.include?(charset) options[:collation] = collation.sub(/\A[^_]+/, "utf8") end end @@ -67,23 +97,76 @@ module ActiveRecord MySQL::SchemaDumper.create(self, options) end + # Maps logical Rails types to MySQL-specific data types. + def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_size(limit, type), unsigned: nil, **) + sql = + case type.to_s + when "integer" + integer_to_sql(limit) + when "text" + type_with_size_to_sql("text", size) + when "blob" + type_with_size_to_sql("blob", size) + when "binary" + if (0..0xfff) === limit + "varbinary(#{limit})" + else + type_with_size_to_sql("blob", size) + end + else + super + end + + sql = "#{sql} unsigned" if unsigned && type != :primary_key + sql + end + + def table_alias_length + 256 # https://dev.mysql.com/doc/refman/8.0/en/identifiers.html + end + private CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"] + def row_format_dynamic_by_default? + if mariadb? + database_version >= "10.2.2" + else + database_version >= "5.7.9" + end + end + + def default_row_format + return if row_format_dynamic_by_default? + + unless defined?(@default_row_format) + if query_value("SELECT @@innodb_file_per_table = 1 AND @@innodb_file_format = 'Barracuda'") == 1 + @default_row_format = "ROW_FORMAT=DYNAMIC" + else + @default_row_format = nil + end + end + + @default_row_format + end + def schema_creation MySQL::SchemaCreation.new(self) end def create_table_definition(*args) - MySQL::TableDefinition.new(*args) + MySQL::TableDefinition.new(self, *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(?:\(\))?\z/i.match?(field[:Default]) - default, default_function = nil, "CURRENT_TIMESTAMP" - else - default, default_function = field[:Default], nil + default, default_function = field[:Default], nil + + if type_metadata.type == :datetime && /\ACURRENT_TIMESTAMP(?:\([0-6]?\))?\z/i.match?(default) + default, default_function = nil, default + elsif type_metadata.extra == "DEFAULT_GENERATED" + default = +"(#{default})" unless default.start_with?("(") + default, default_function = nil, default end MySQL::Column.new( @@ -91,9 +174,8 @@ module ActiveRecord default, type_metadata, field[:Null] == "YES", - table_name, default_function, - field[:Collation], + collation: field[:Collation], comment: field[:Comment].presence ) end @@ -121,7 +203,7 @@ module ActiveRecord def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) - sql = "SELECT table_name FROM information_schema.tables".dup + sql = +"SELECT table_name FROM information_schema.tables" sql << " WHERE table_schema = #{scope[:schema]}" sql << " AND table_name = #{scope[:name]}" if scope[:name] sql << " AND table_type = #{scope[:type]}" if scope[:type] @@ -142,6 +224,40 @@ module ActiveRecord schema, name = nil, schema unless name [schema, name] end + + def type_with_size_to_sql(type, size) + case size&.to_s + when nil, "tiny", "medium", "long" + "#{size}#{type}" + else + raise ArgumentError, + "#{size.inspect} is invalid :size value. Only :tiny, :medium, and :long are allowed." + end + end + + def limit_to_size(limit, type) + case type.to_s + when "text", "blob", "binary" + case limit + when 0..0xff; "tiny" + when nil, 0x100..0xffff; nil + when 0x10000..0xffffff; "medium" + when 0x1000000..0xffffffff; "long" + else raise ArgumentError, "No #{type} type has byte size #{limit}" + end + end + end + + def integer_to_sql(limit) + case limit + when 1; "tinyint" + when 2; "smallint" + when 3; "mediumint" + when nil, 4; "int" + when 5..8; "bigint" + else raise ArgumentError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead." + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb index 7ad0944d51..56479f27bf 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb @@ -16,19 +16,16 @@ module ActiveRecord def ==(other) other.is_a?(MySQL::TypeMetadata) && - attributes_for_hash == other.attributes_for_hash + __getobj__ == other.__getobj__ && + extra == other.extra end alias eql? == def hash - attributes_for_hash.hash + TypeMetadata.hash ^ + __getobj__.hash ^ + extra.hash end - - protected - - def attributes_for_hash - [self.class, @type_metadata, extra] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 4c57bd48ab..0dc880c731 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -3,7 +3,7 @@ require "active_record/connection_adapters/abstract_mysql_adapter" require "active_record/connection_adapters/mysql/database_statements" -gem "mysql2", ">= 0.4.4", "< 0.6.0" +gem "mysql2", ">= 0.4.4" require "mysql2" module ActiveRecord @@ -14,7 +14,7 @@ module ActiveRecord config[:flags] ||= 0 if config[:flags].kind_of? Array - config[:flags].push "FOUND_ROWS".freeze + config[:flags].push "FOUND_ROWS" else config[:flags] |= Mysql2::Client::FOUND_ROWS end @@ -32,7 +32,7 @@ module ActiveRecord module ConnectionAdapters class Mysql2Adapter < AbstractMysqlAdapter - ADAPTER_NAME = "Mysql2".freeze + ADAPTER_NAME = "Mysql2" include MySQL::DatabaseStatements @@ -43,7 +43,7 @@ module ActiveRecord end def supports_json? - !mariadb? && version >= "5.7.8" + !mariadb? && database_version >= "5.7.8" end def supports_comments? @@ -58,6 +58,10 @@ module ActiveRecord true end + def supports_lazy_transactions? + true + end + # HELPER METHODS =========================================== def each_hash(result) # :nodoc: @@ -117,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 3ccc7271ab..ef98d2b37a 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb @@ -2,42 +2,21 @@ module ActiveRecord module ConnectionAdapters - # PostgreSQL-specific extensions to column definitions in a table. - class PostgreSQLColumn < Column #:nodoc: - delegate :array, :oid, :fmod, to: :sql_type_metadata - alias :array? :array - - def initialize(*, max_identifier_length: 63, **) - super - @max_identifier_length = max_identifier_length - end - - def serial? - return unless default_function - - if %r{\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z} =~ default_function - sequence_name_from_parts(table_name, name, suffix) == sequence_name + module PostgreSQL + class Column < ConnectionAdapters::Column # :nodoc: + delegate :array, :oid, :fmod, to: :sql_type_metadata + alias :array? :array + + def initialize(*, serial: nil, **) + super + @serial = serial end - end - private - attr_reader :max_identifier_length - - def sequence_name_from_parts(table_name, column_name, suffix) - over_length = [table_name, column_name, suffix].map(&:length).sum + 2 - max_identifier_length - - if over_length > 0 - column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min - over_length -= column_name.length - column_name_length - column_name = column_name[0, column_name_length - [over_length, 0].min] - end - - if over_length > 0 - table_name = table_name[0, table_name.length - over_length] - end - - "#{table_name}_#{column_name}_#{suffix}" + def serial? + @serial end + end end + PostgreSQLColumn = PostgreSQL::Column # :nodoc: end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb index 8db2a645af..d872bd662f 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -58,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) @@ -65,11 +67,24 @@ module ActiveRecord end end + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :set, :show, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + # Executes an SQL statement, returning a PG::Result object on success # or raising a PG::Error exception otherwise. # 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) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @connection.async_exec(sql) @@ -95,7 +110,7 @@ module ActiveRecord end alias :exec_update :exec_delete - def sql_for_insert(sql, pk, id_value, sequence_name, binds) # :nodoc: + def sql_for_insert(sql, pk, binds) # :nodoc: if pk.nil? # Extract the table from the insert sql. Yuck. table_ref = extract_table_ref_from_insert_sql(sql) @@ -149,6 +164,10 @@ module ActiveRecord end private + def build_truncate_statements(*table_names) + "TRUNCATE TABLE #{table_names.map(&method(:quote_table_name)).join(", ")}" + end + # Returns the current ID of a table's sequence. def last_insert_id_result(sequence_name) exec_query("SELECT currval(#{quote(sequence_name)})", "SQL") diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb index d6852082ac..b1dfbde86e 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb @@ -5,7 +5,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Array < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable Data = Struct.new(:encoder, :values) # :nodoc: @@ -33,7 +33,13 @@ module ActiveRecord def cast(value) if value.is_a?(::String) - value = @pg_decoder.decode(value) + value = begin + @pg_decoder.decode(value) + rescue TypeError + # malformed array string is treated as [], will raise in PG 2.0 gem + # this keeps a consistent implementation + [] + end end type_cast_array(value, :cast) end @@ -66,6 +72,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/hstore.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb index aabe83b85d..7b42677101 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb @@ -5,7 +5,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Hstore < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable def type :hstore 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 7b057a8452..7f6adc351c 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 @@ -5,7 +5,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class LegacyPoint < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable def type :point 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 02a9c506f6..8c74cecc4d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb @@ -7,7 +7,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Point < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable def type :point 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 6edb7cfd3c..aa7701e038 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb @@ -53,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) @@ -60,7 +64,7 @@ module ActiveRecord end def type_cast_single_for_database(value) - infinity?(value) ? value : @subtype.serialize(value) + infinity?(value) ? value : @subtype.serialize(@subtype.cast(value)) end def extract_bounds(value) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb index 231278c184..203087bc36 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,5 +1,7 @@ # frozen_string_literal: true +require "active_support/core_ext/array/extract" + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -16,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" } + enums = nodes.extract! { |row| row["typtype"] == "e" } + domains = nodes.extract! { |row| row["typtype"] == "d" } + arrays = nodes.extract! { |row| row["typinput"] == "array_in" } + composites = nodes.extract! { |row| row["typelem"].to_i != 0 } mapped.each { |row| register_mapped_type(row) } enums.each { |row| register_enum_type(row) } @@ -34,7 +36,7 @@ module ActiveRecord 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(", ")] + <<~SQL % [known_type_names.join(", "), known_type_types.join(", ")] WHERE t.typname IN (%s) OR t.typtype IN (%s) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb index bc9b8dbfcf..28abdbd073 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb @@ -13,9 +13,12 @@ module ActiveRecord :uuid end - def cast(value) - value.to_s[ACCEPTABLE_UUID, 0] - end + private + + def cast_value(value) + casted = value.to_s + casted if casted.match?(ACCEPTABLE_UUID) + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb index e75202b0be..d40e0ef1f0 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb @@ -93,11 +93,11 @@ module ActiveRecord elsif value.hex? "X'#{value}'" end - when Float - if value.infinite? || value.nan? - "'#{value}'" - else + when Numeric + if value.finite? super + else + "'#{value}'" end when OID::Array::Data _quote(encode_array(value)) @@ -138,7 +138,7 @@ module ActiveRecord end def encode_range(range) - "[#{type_cast_range_value(range.first)},#{type_cast_range_value(range.last)}#{range.exclude_end? ? ')' : ']'}" + "[#{type_cast_range_value(range.begin)},#{type_cast_range_value(range.end)}#{range.exclude_end? ? ')' : ']'}" end def determine_encoding_of_strings_in_array(value) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb index 8e381a92cf..84dd28907b 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb @@ -17,12 +17,59 @@ module ActiveRecord "VALIDATE CONSTRAINT #{quote_column_name(name)}" end + def visit_ChangeColumnDefinition(o) + column = o.column + column.sql_type = type_to_sql(column.type, column.options) + quoted_column_name = quote_column_name(o.name) + + change_column_sql = +"ALTER COLUMN #{quoted_column_name} TYPE #{column.sql_type}" + + options = column_options(column) + + if options[:collation] + change_column_sql << " COLLATE \"#{options[:collation]}\"" + end + + if options[:using] + change_column_sql << " USING #{options[:using]}" + elsif options[:cast_as] + cast_as_type = type_to_sql(options[:cast_as], options) + change_column_sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})" + end + + if options.key?(:default) + if options[:default].nil? + change_column_sql << ", ALTER COLUMN #{quoted_column_name} DROP DEFAULT" + else + quoted_default = quote_default_expression(options[:default], column) + change_column_sql << ", ALTER COLUMN #{quoted_column_name} SET DEFAULT #{quoted_default}" + end + end + + if options.key?(:null) + change_column_sql << ", ALTER COLUMN #{quoted_column_name} #{options[:null] ? 'DROP' : 'SET'} NOT NULL" + end + + change_column_sql + end + def add_column_options!(sql, options) if options[:collation] sql << " COLLATE \"#{options[:collation]}\"" end super end + + # Returns any SQL string to go between CREATE and TABLE. May be nil. + def table_modifier_in_create(o) + # A table cannot be both TEMPORARY and UNLOGGED, since all TEMPORARY + # tables are already UNLOGGED. + if o.temporary + " TEMPORARY" + elsif o.unlogged + " UNLOGGED" + end + end end end end 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 6047217fcd..3bb7c52899 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb @@ -4,6 +4,8 @@ module ActiveRecord module ConnectionAdapters module PostgreSQL module ColumnMethods + extend ActiveSupport::Concern + # Defines the primary key field. # Use of the native PostgreSQL UUID type is supported, and can be used # by defining your tables as such: @@ -13,10 +15,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()" @@ -51,130 +53,144 @@ module ActiveRecord super end - def bigserial(*args, **options) - args.each { |name| column(name, :bigserial, options) } - end + ## + # :method: bigserial + # :call-seq: bigserial(*names, **options) - def bit(*args, **options) - args.each { |name| column(name, :bit, options) } - end + ## + # :method: bit + # :call-seq: bit(*names, **options) - def bit_varying(*args, **options) - args.each { |name| column(name, :bit_varying, options) } - end + ## + # :method: bit_varying + # :call-seq: bit_varying(*names, **options) - def cidr(*args, **options) - args.each { |name| column(name, :cidr, options) } - end + ## + # :method: cidr + # :call-seq: cidr(*names, **options) - def citext(*args, **options) - args.each { |name| column(name, :citext, options) } - end + ## + # :method: citext + # :call-seq: citext(*names, **options) - def daterange(*args, **options) - args.each { |name| column(name, :daterange, options) } - end + ## + # :method: daterange + # :call-seq: daterange(*names, **options) - def hstore(*args, **options) - args.each { |name| column(name, :hstore, options) } - end + ## + # :method: hstore + # :call-seq: hstore(*names, **options) - def inet(*args, **options) - args.each { |name| column(name, :inet, options) } - end + ## + # :method: inet + # :call-seq: inet(*names, **options) - def interval(*args, **options) - args.each { |name| column(name, :interval, options) } - end + ## + # :method: interval + # :call-seq: interval(*names, **options) - def int4range(*args, **options) - args.each { |name| column(name, :int4range, options) } - end + ## + # :method: int4range + # :call-seq: int4range(*names, **options) - def int8range(*args, **options) - args.each { |name| column(name, :int8range, options) } - end + ## + # :method: int8range + # :call-seq: int8range(*names, **options) - def jsonb(*args, **options) - args.each { |name| column(name, :jsonb, options) } - end + ## + # :method: jsonb + # :call-seq: jsonb(*names, **options) - def ltree(*args, **options) - args.each { |name| column(name, :ltree, options) } - end + ## + # :method: ltree + # :call-seq: ltree(*names, **options) - def macaddr(*args, **options) - args.each { |name| column(name, :macaddr, options) } - end + ## + # :method: macaddr + # :call-seq: macaddr(*names, **options) - def money(*args, **options) - args.each { |name| column(name, :money, options) } - end + ## + # :method: money + # :call-seq: money(*names, **options) - def numrange(*args, **options) - args.each { |name| column(name, :numrange, options) } - end + ## + # :method: numrange + # :call-seq: numrange(*names, **options) - def oid(*args, **options) - args.each { |name| column(name, :oid, options) } - end + ## + # :method: oid + # :call-seq: oid(*names, **options) - def point(*args, **options) - args.each { |name| column(name, :point, options) } - end + ## + # :method: point + # :call-seq: point(*names, **options) - def line(*args, **options) - args.each { |name| column(name, :line, options) } - end + ## + # :method: line + # :call-seq: line(*names, **options) - def lseg(*args, **options) - args.each { |name| column(name, :lseg, options) } - end + ## + # :method: lseg + # :call-seq: lseg(*names, **options) - def box(*args, **options) - args.each { |name| column(name, :box, options) } - end + ## + # :method: box + # :call-seq: box(*names, **options) - def path(*args, **options) - args.each { |name| column(name, :path, options) } - end + ## + # :method: path + # :call-seq: path(*names, **options) - def polygon(*args, **options) - args.each { |name| column(name, :polygon, options) } - end + ## + # :method: polygon + # :call-seq: polygon(*names, **options) - def circle(*args, **options) - args.each { |name| column(name, :circle, options) } - end + ## + # :method: circle + # :call-seq: circle(*names, **options) - def serial(*args, **options) - args.each { |name| column(name, :serial, options) } - end + ## + # :method: serial + # :call-seq: serial(*names, **options) - def tsrange(*args, **options) - args.each { |name| column(name, :tsrange, options) } - end + ## + # :method: tsrange + # :call-seq: tsrange(*names, **options) - def tstzrange(*args, **options) - args.each { |name| column(name, :tstzrange, options) } - end + ## + # :method: tstzrange + # :call-seq: tstzrange(*names, **options) - def tsvector(*args, **options) - args.each { |name| column(name, :tsvector, options) } - end + ## + # :method: tsvector + # :call-seq: tsvector(*names, **options) - def uuid(*args, **options) - args.each { |name| column(name, :uuid, options) } - end + ## + # :method: uuid + # :call-seq: uuid(*names, **options) + + ## + # :method: xml + # :call-seq: xml(*names, **options) - def xml(*args, **options) - args.each { |name| column(name, :xml, options) } + included do + define_column_methods :bigserial, :bit, :bit_varying, :cidr, :citext, :daterange, + :hstore, :inet, :interval, :int4range, :int8range, :jsonb, :ltree, :macaddr, + :money, :numrange, :oid, :point, :line, :lseg, :box, :path, :polygon, :circle, + :serial, :tsrange, :tstzrange, :tsvector, :uuid, :xml end end class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition include ColumnMethods + attr_reader :unlogged + + def initialize(*) + super + @unlogged = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables + end + private def integer_like_primary_key_type(type, options) if type == :bigint || options[:limit] == 8 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 e20e5f2914..c412d1f34c 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -22,8 +22,8 @@ module ActiveRecord def create_database(name, options = {}) options = { encoding: "utf8" }.merge!(options.symbolize_keys) - option_string = options.inject("") do |memo, (key, value)| - memo += case key + option_string = options.each_with_object(+"") do |(key, value), memo| + memo << case key when :owner " OWNER = \"#{value}\"" when :template @@ -68,7 +68,7 @@ module ActiveRecord table = quoted_scope(table_name) index = quoted_scope(index_name) - query_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 @@ -85,7 +85,7 @@ module ActiveRecord def indexes(table_name) # :nodoc: scope = quoted_scope(table_name) - result = query(<<-SQL, "SCHEMA") + 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 FROM pg_class t @@ -124,7 +124,7 @@ module ActiveRecord # 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| + 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(" ") @@ -196,7 +196,7 @@ module ActiveRecord # Returns an array of schema names. def schema_names - query_values(<<-SQL, "SCHEMA") + query_values(<<~SQL, "SCHEMA") SELECT nspname FROM pg_namespace WHERE nspname !~ '^pg_.*' @@ -287,7 +287,7 @@ module ActiveRecord quoted_sequence = quote_table_name(sequence) max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA") if max_pk.nil? - if postgresql_version >= 100000 + if database_version >= 100000 minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA") else minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA") @@ -302,7 +302,7 @@ module ActiveRecord def pk_and_sequence_for(table) #:nodoc: # First try looking for a sequence with a dependency on the # given table's primary key. - result = query(<<-end_sql, "SCHEMA")[0] + result = query(<<~SQL, "SCHEMA")[0] SELECT attr.attname, nsp.nspname, seq.relname FROM pg_class seq, pg_attribute attr, @@ -319,10 +319,10 @@ module ActiveRecord AND cons.contype = 'p' AND dep.classid = 'pg_class'::regclass AND dep.refobjid = #{quote(quote_table_name(table))}::regclass - end_sql + SQL if result.nil? || result.empty? - result = query(<<-end_sql, "SCHEMA")[0] + result = query(<<~SQL, "SCHEMA")[0] SELECT attr.attname, nsp.nspname, CASE WHEN pg_get_expr(def.adbin, def.adrelid) !~* 'nextval' THEN NULL @@ -339,7 +339,7 @@ module ActiveRecord 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 + SQL end pk = result.shift @@ -548,21 +548,21 @@ module ActiveRecord # The hard limit is 1GB, because of a 32-bit size field, and TOAST. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "No binary type has byte size #{limit}.") + else raise ArgumentError, "No binary type has byte size #{limit}. The limit on binary can be at most 1GB - 1byte." end when "text" # PostgreSQL doesn't support limits on text columns. # The hard limit is 1GB, according to section 8.3 in the manual. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "The limit on text can be at most 1GB - 1byte.") + else raise ArgumentError, "No text type has byte size #{limit}. The limit on text can be at most 1GB - 1byte." end when "integer" case limit when 1, 2; "smallint" when nil, 3, 4; "integer" when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead.") + else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead." end else super @@ -623,10 +623,10 @@ module ActiveRecord # validate_foreign_key :accounts, name: :special_fk_name # # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. - def validate_foreign_key(from_table, options_or_to_table = {}) + def validate_foreign_key(from_table, to_table = nil, **options) return unless supports_validate_constraints? - fk_name_to_validate = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_validate = foreign_key_for!(from_table, to_table: to_table, **options).name validate_constraint from_table, fk_name_to_validate end @@ -637,7 +637,7 @@ module ActiveRecord end def create_table_definition(*args) - PostgreSQL::TableDefinition.new(*args) + PostgreSQL::TableDefinition.new(self, *args) end def create_alter_table(name) @@ -650,16 +650,19 @@ module ActiveRecord default_value = extract_value_from_default(default) default_function = extract_default_function(default_value, default) - PostgreSQLColumn.new( + if match = default_function&.match(/\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z/) + serial = sequence_name_from_parts(table_name, column_name, match[:suffix]) == match[:sequence_name] + end + + PostgreSQL::Column.new( column_name, default_value, type_metadata, !notnull, - table_name, default_function, - collation, + collation: collation, comment: comment.presence, - max_identifier_length: max_identifier_length + serial: serial ) end @@ -675,6 +678,22 @@ module ActiveRecord PostgreSQLTypeMetadata.new(simple_type, oid: oid, fmod: fmod) end + def sequence_name_from_parts(table_name, column_name, suffix) + over_length = [table_name, column_name, suffix].sum(&:length) + 2 - max_identifier_length + + if over_length > 0 + column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min + over_length -= column_name.length - column_name_length + column_name = column_name[0, column_name_length - [over_length, 0].min] + end + + if over_length > 0 + table_name = table_name[0, table_name.length - over_length] + end + + "#{table_name}_#{column_name}_#{suffix}" + end + def extract_foreign_key_action(specifier) case specifier when "c"; :cascade @@ -683,34 +702,20 @@ module ActiveRecord 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 + 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) + td = create_table_definition(table_name) + cd = td.new_column_definition(column_name, type, options) + sqls = [schema_creation.accept(ChangeColumnDefinition.new(cd, column_name))] 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: + def change_column_default_for_alter(table_name, column_name, default_or_changes) column = column_for(table_name, column_name) return unless column @@ -725,11 +730,17 @@ module ActiveRecord 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" + def change_column_null_for_alter(table_name, column_name, null, default = nil) + "ALTER COLUMN #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL" end def add_timestamps_for_alter(table_name, options = {}) + options[:null] = false if options[:null].nil? + + if !options.key?(:precision) && supports_datetime_with_precision? + options[:precision] = 6 + end + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] end @@ -751,9 +762,9 @@ module ActiveRecord def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) - scope[:type] ||= "'r','v','m','f'" # (r)elation/table, (v)iew, (m)aterialized view, (f)oreign table + 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 = +"SELECT c.relname FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace" sql << " WHERE n.nspname = #{scope[:schema]}" sql << " AND c.relname = #{scope[:name]}" if scope[:name] sql << " AND c.relkind IN (#{scope[:type]})" @@ -765,7 +776,7 @@ module ActiveRecord type = \ case type when "BASE TABLE" - "'r'" + "'r','p'" when "VIEW" "'v','m'" when "FOREIGN TABLE" 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 b252a76caa..403b3ead98 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,7 @@ # frozen_string_literal: true module ActiveRecord + # :stopdoc: module ConnectionAdapters class PostgreSQLTypeMetadata < DelegateClass(SqlTypeMetadata) undef to_yaml if method_defined?(:to_yaml) @@ -16,24 +17,25 @@ module ActiveRecord end def sql_type - super.gsub(/\[\]$/, "".freeze) + super.gsub(/\[\]$/, "") end def ==(other) other.is_a?(PostgreSQLTypeMetadata) && - attributes_for_hash == other.attributes_for_hash + __getobj__ == other.__getobj__ && + oid == other.oid && + fmod == other.fmod && + array == other.array end alias eql? == def hash - attributes_for_hash.hash + PostgreSQLTypeMetadata.hash ^ + __getobj__.hash ^ + oid.hash ^ + fmod.hash ^ + array.hash end - - protected - - def attributes_for_hash - [self.class, @type_metadata, oid, fmod] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb b/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb index bfd300723d..f2f4701500 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb @@ -68,7 +68,7 @@ module ActiveRecord # * <tt>"schema_name".table_name</tt> # * <tt>"schema.name"."table name"</tt> def extract_schema_qualified_name(string) - schema, table = string.scan(/[^".\s]+|"[^"]*"/) + schema, table = string.scan(/[^".]+|"[^"]*"/) if table.nil? table = schema schema = nil diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index fdf6f75108..91318a0af1 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -4,6 +4,14 @@ gem "pg", ">= 0.18", "< 2.0" require "pg" +# Use async_exec instead of exec_params on pg versions before 1.1 +class ::PG::Connection # :nodoc: + unless self.public_method_defined?(:async_exec_params) + remove_method :exec_params + alias exec_params async_exec + end +end + require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/postgresql/column" @@ -35,9 +43,14 @@ module ActiveRecord 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 PG::Connection object, - # so just pass a nil connection object for the time being. - ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config) + conn = PG.connect(conn_params) + ConnectionAdapters::PostgreSQLAdapter.new(conn, logger, conn_params, config) + rescue ::PG::Error => error + if error.message.include?("does not exist") + raise ActiveRecord::NoDatabaseError + else + raise + end end end @@ -70,7 +83,20 @@ module ActiveRecord # In addition, default connection parameters of libpq can be set per environment variables. # See https://www.postgresql.org/docs/current/static/libpq-envars.html . class PostgreSQLAdapter < AbstractAdapter - ADAPTER_NAME = "PostgreSQL".freeze + ADAPTER_NAME = "PostgreSQL" + + ## + # :singleton-method: + # PostgreSQL allows the creation of "unlogged" tables, which do not record + # data in the PostgreSQL Write-Ahead Log. This can make the tables faster, + # but significantly increases the risk of data loss if the database + # crashes. As a result, this should not be used in production + # environments. If you would like all created tables to be unlogged in + # the test environment you can add the following line to your test.rb + # file: + # + # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + class_attribute :create_unlogged_tables, default: false NATIVE_DATABASE_TYPES = { primary_key: "bigserial primary key", @@ -159,7 +185,7 @@ module ActiveRecord end def supports_json? - postgresql_version >= 90200 + true end def supports_comments? @@ -170,6 +196,17 @@ module ActiveRecord true end + def supports_insert_returning? + true + end + + def supports_insert_on_conflict? + database_version >= 90500 + end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? + def index_algorithms { concurrently: "CONCURRENTLY" } end @@ -212,15 +249,8 @@ module ActiveRecord @local_tz = nil @max_identifier_length = nil - connect + configure_connection add_pg_encoders - @statements = StatementPool.new @connection, - self.class.type_cast_config_to_integer(config[:statement_limit]) - - if postgresql_version < 90100 - raise "Your version of PostgreSQL (#{postgresql_version}) is too old. Active Record supports PostgreSQL >= 9.1." - end - add_pg_decoders @type_map = Type::HashLookupTypeMap.new @@ -229,17 +259,6 @@ module ActiveRecord @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true end - # Clears the prepared statements cache. - def clear_cache! - @lock.synchronize do - @statements.clear - end - end - - def truncate(table_name, name = nil) - exec_query "TRUNCATE TABLE #{quote_table_name(table_name)}", name, [] - end - # Is this connection alive and ready for queries? def active? @lock.synchronize do @@ -256,6 +275,8 @@ module ActiveRecord super @connection.reset configure_connection + rescue PG::ConnectionBad + connect end end @@ -310,20 +331,31 @@ module ActiveRecord end def supports_ranges? - # Range datatypes weren't introduced until PostgreSQL 9.2 - postgresql_version >= 90200 + true end + deprecate :supports_ranges? def supports_materialized_views? - postgresql_version >= 90300 + true end def supports_foreign_tables? - postgresql_version >= 90300 + true end def supports_pgcrypto_uuid? - postgresql_version >= 90400 + database_version >= 90400 + end + + def supports_optimizer_hints? + unless defined?(@has_pg_hint_plan) + @has_pg_hint_plan = extension_available?("pg_hint_plan") + end + @has_pg_hint_plan + end + + def supports_lazy_transactions? + true end def get_advisory_lock(lock_id) # :nodoc: @@ -352,9 +384,12 @@ module ActiveRecord } end + def extension_available?(name) + query_value("SELECT true FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") + end + def extension_enabled?(name) - res = exec_query("SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", "SCHEMA") - res.cast_values.first + query_value("SELECT installed_version IS NOT NULL FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") end def extensions @@ -365,8 +400,6 @@ module ActiveRecord def max_identifier_length @max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i end - alias table_alias_length max_identifier_length - alias index_name_length max_identifier_length # Set the authorized user for this session def session_auth=(user) @@ -389,15 +422,37 @@ module ActiveRecord } # Returns the version of the connected PostgreSQL server. - def postgresql_version + def get_database_version # :nodoc: @connection.server_version end + alias :postgresql_version :database_version def default_index_type?(index) # :nodoc: index.using == :btree || super end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") + end + + sql << " RETURNING #{insert.returning}" if insert.returning + sql + end + + def check_version # :nodoc: + if database_version < 90300 + raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3." + end + end + private + # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" NUMERIC_VALUE_OUT_OF_RANGE = "22003" @@ -409,34 +464,34 @@ module ActiveRecord LOCK_NOT_AVAILABLE = "55P03" QUERY_CANCELED = "57014" - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) return exception unless exception.respond_to?(:result) case exception.result.try(:error_field, PG::PG_DIAG_SQLSTATE) when UNIQUE_VIOLATION - RecordNotUnique.new(message) + RecordNotUnique.new(message, sql: sql, binds: binds) when FOREIGN_KEY_VIOLATION - InvalidForeignKey.new(message) + InvalidForeignKey.new(message, sql: sql, binds: binds) when VALUE_LIMIT_VIOLATION - ValueTooLong.new(message) + ValueTooLong.new(message, sql: sql, binds: binds) when NUMERIC_VALUE_OUT_OF_RANGE - RangeError.new(message) + RangeError.new(message, sql: sql, binds: binds) when NOT_NULL_VIOLATION - NotNullViolation.new(message) + NotNullViolation.new(message, sql: sql, binds: binds) when SERIALIZATION_FAILURE - SerializationFailure.new(message) + SerializationFailure.new(message, sql: sql, binds: binds) when DEADLOCK_DETECTED - Deadlocked.new(message) + Deadlocked.new(message, sql: sql, binds: binds) when LOCK_NOT_AVAILABLE - LockWaitTimeout.new(message) + LockWaitTimeout.new(message, sql: sql, binds: binds) when QUERY_CANCELED - QueryCanceled.new(message) + QueryCanceled.new(message, sql: sql, binds: binds) else super end end - def get_oid_type(oid, fmod, column_name, sql_type = "".freeze) + def get_oid_type(oid, fmod, column_name, sql_type = "") if !type_map.key?(oid) load_additional_types([oid]) end @@ -525,13 +580,13 @@ module ActiveRecord # Quoted types when /\A[\(B]?'(.*)'.*::"?([\w. ]+)"?(?:\[\])?\z/m # The default 'now'::date is CURRENT_DATE - if $1 == "now".freeze && $2 == "date".freeze + if $1 == "now" && $2 == "date" nil else - $1.gsub("''".freeze, "'".freeze) + $1.gsub("''", "'") end # Boolean types - when "true".freeze, "false".freeze + when "true", "false" default # Numeric types when /\A\(?(-?\d+(\.\d*)?)\)?(::bigint)?\z/ @@ -557,18 +612,11 @@ module ActiveRecord def load_additional_types(oids = nil) initializer = OID::TypeMapInitializer.new(type_map) - if supports_ranges? - query = <<-SQL - SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype - FROM pg_type as t - LEFT JOIN pg_range as r ON oid = rngtypid - SQL - else - query = <<-SQL - SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, t.typtype, t.typbasetype - FROM pg_type as t - SQL - end + query = <<~SQL + SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype + FROM pg_type as t + LEFT JOIN pg_range as r ON oid = rngtypid + SQL if oids query += "WHERE t.oid::integer IN (%s)" % oids.join(", ") @@ -584,6 +632,10 @@ module ActiveRecord FEATURE_NOT_SUPPORTED = "0A000" #:nodoc: def execute_and_clear(sql, name, binds, prepare: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + if without_prepared_statement?(binds) result = exec_no_cache(sql, name, []) elsif !prepare @@ -597,16 +649,25 @@ module ActiveRecord end def exec_no_cache(sql, name, binds) + materialize_transactions + + # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been + # made since we established the connection + update_typemap_for_default_timezone + type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.async_exec(sql, type_casted_binds) + @connection.exec_params(sql, type_casted_binds) end end end def exec_cache(sql, name, binds) - stmt_key = prepare_statement(sql) + materialize_transactions + update_typemap_for_default_timezone + + stmt_key = prepare_statement(sql, binds) type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds, stmt_key) do @@ -639,7 +700,7 @@ module ActiveRecord # # Check here for more details: # 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 + CACHED_PLAN_HEURISTIC = "cached plan must not change result type" def is_cached_plan_failure?(e) pgerror = e.cause code = pgerror.result.result_error_field(PG::PG_DIAG_SQLSTATE) @@ -660,7 +721,7 @@ module ActiveRecord # Prepare the statement if it hasn't been prepared, return # the statement key. - def prepare_statement(sql) + def prepare_statement(sql, binds) @lock.synchronize do sql_key = sql_key(sql) unless @statements.key? sql_key @@ -668,7 +729,7 @@ module ActiveRecord begin @connection.prepare nextkey, sql rescue => e - raise translate_exception_class(e, sql) + raise translate_exception_class(e, sql, binds) end # Clear the queue @connection.get_last_result @@ -683,12 +744,8 @@ module ActiveRecord def connect @connection = PG.connect(@connection_parameters) configure_connection - rescue ::PG::Error => error - if error.message.include?("does not exist") - raise ActiveRecord::NoDatabaseError - else - raise - end + add_pg_encoders + add_pg_decoders end # Configures the encoding, verbosity, schema search path, and time zone of the connection. @@ -746,7 +803,7 @@ module ActiveRecord # - format_type includes the column size constraint, e.g. varchar(50) # - ::regclass is a function that gives the id for a table name def column_definitions(table_name) - query(<<-end_sql, "SCHEMA") + query(<<~SQL, "SCHEMA") SELECT a.attname, format_type(a.atttypid, a.atttypmod), pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, c.collname, col_description(a.attrelid, a.attnum) AS comment @@ -757,7 +814,7 @@ module ActiveRecord WHERE a.attrelid = #{quote(quote_table_name(table_name))}::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum - end_sql + SQL end def extract_table_ref_from_insert_sql(sql) @@ -769,10 +826,14 @@ module ActiveRecord Arel::Visitors::PostgreSQL.new(self) end + def build_statement_pool + StatementPool.new(@connection, self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + def can_perform_case_insensitive_comparison_for?(column) @case_insensitive_cache ||= {} @case_insensitive_cache[column.sql_type] ||= begin - sql = <<-end_sql + sql = <<~SQL SELECT exists( SELECT * FROM pg_proc WHERE proname = 'lower' @@ -784,7 +845,7 @@ module ActiveRecord WHERE proname = 'lower' AND castsource = #{quote column.sql_type}::regtype ) - end_sql + SQL execute_and_clear(sql, "SCHEMA", []) do |result| result.getvalue(0, 0) end @@ -799,7 +860,22 @@ module ActiveRecord @connection.type_map_for_queries = map end + def update_typemap_for_default_timezone + if @default_timezone != ActiveRecord::Base.default_timezone && @timestamp_decoder + decoder_class = ActiveRecord::Base.default_timezone == :utc ? + PG::TextDecoder::TimestampUtc : + PG::TextDecoder::TimestampWithoutTimeZone + + @timestamp_decoder = decoder_class.new(@timestamp_decoder.to_h) + @connection.type_map_for_results.add_coder(@timestamp_decoder) + @default_timezone = ActiveRecord::Base.default_timezone + end + end + def add_pg_decoders + @default_timezone = nil + @timestamp_decoder = nil + coders_by_name = { "int2" => PG::TextDecoder::Integer, "int4" => PG::TextDecoder::Integer, @@ -809,8 +885,15 @@ module ActiveRecord "float8" => PG::TextDecoder::Float, "bool" => PG::TextDecoder::Boolean, } + + if defined?(PG::TextDecoder::TimestampUtc) + # Use native PG encoders available since pg-1.1 + coders_by_name["timestamp"] = PG::TextDecoder::TimestampUtc + coders_by_name["timestamptz"] = PG::TextDecoder::TimestampWithTimeZone + end + known_coder_types = coders_by_name.keys.map { |n| quote(n) } - query = <<-SQL % known_coder_types.join(", ") + query = <<~SQL % known_coder_types.join(", ") SELECT t.oid, t.typname FROM pg_type as t WHERE t.typname IN (%s) @@ -824,6 +907,10 @@ module ActiveRecord map = PG::TypeMapByOid.new coders.each { |coder| map.add_coder(coder) } @connection.type_map_for_results = map + + # extract timestamp decoder for use in update_typemap_for_default_timezone + @timestamp_decoder = coders.find { |coder| coder.name == "timestamp" } + update_typemap_for_default_timezone end def construct_coder(row, coder_class) diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index c29cf1f9a1..dbfe1e4a34 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -13,6 +13,7 @@ module ActiveRecord @columns_hash = {} @primary_keys = {} @data_sources = {} + @indexes = {} end def initialize_dup(other) @@ -21,22 +22,27 @@ module ActiveRecord @columns_hash = @columns_hash.dup @primary_keys = @primary_keys.dup @data_sources = @data_sources.dup + @indexes = @indexes.dup end def encode_with(coder) - coder["columns"] = @columns - coder["columns_hash"] = @columns_hash - coder["primary_keys"] = @primary_keys - coder["data_sources"] = @data_sources - coder["version"] = connection.migration_context.current_version + coder["columns"] = @columns + coder["columns_hash"] = @columns_hash + coder["primary_keys"] = @primary_keys + coder["data_sources"] = @data_sources + coder["indexes"] = @indexes + coder["version"] = connection.migration_context.current_version + coder["database_version"] = database_version end def init_with(coder) - @columns = coder["columns"] - @columns_hash = coder["columns_hash"] - @primary_keys = coder["primary_keys"] - @data_sources = coder["data_sources"] - @version = coder["version"] + @columns = coder["columns"] + @columns_hash = coder["columns_hash"] + @primary_keys = coder["primary_keys"] + @data_sources = coder["data_sources"] + @indexes = coder["indexes"] || {} + @version = coder["version"] + @database_version = coder["database_version"] end def primary_keys(table_name) @@ -57,6 +63,7 @@ module ActiveRecord primary_keys(table_name) columns(table_name) columns_hash(table_name) + indexes(table_name) end end @@ -77,17 +84,32 @@ module ActiveRecord }] end + # Checks whether the columns hash is already cached for a table. + def columns_hash?(table_name) + @columns_hash.key?(table_name) + end + + def indexes(table_name) + @indexes[table_name] ||= connection.indexes(table_name) + end + + def database_version # :nodoc: + @database_version ||= connection.get_database_version + end + # Clears out internal caches def clear! @columns.clear @columns_hash.clear @primary_keys.clear @data_sources.clear + @indexes.clear @version = nil + @database_version = nil end def size - [@columns, @columns_hash, @primary_keys, @data_sources].map(&:size).inject :+ + [@columns, @columns_hash, @primary_keys, @data_sources].sum(&:size) end # Clear out internal caches for the data source +name+. @@ -96,20 +118,21 @@ module ActiveRecord @columns_hash.delete name @primary_keys.delete name @data_sources.delete name + @indexes.delete name end def marshal_dump # if we get current version during initialization, it happens stack over flow. @version = connection.migration_context.current_version - [@version, @columns, @columns_hash, @primary_keys, @data_sources] + [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, database_version] end def marshal_load(array) - @version, @columns, @columns_hash, @primary_keys, @data_sources = array + @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, @database_version = array + @indexes = @indexes || {} end private - def prepare_data_sources connection.data_sources.each { |source| @data_sources[source] = true } end diff --git a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb index 8489bcbf1d..df28df7a7c 100644 --- a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb @@ -16,19 +16,22 @@ module ActiveRecord def ==(other) other.is_a?(SqlTypeMetadata) && - attributes_for_hash == other.attributes_for_hash + sql_type == other.sql_type && + type == other.type && + limit == other.limit && + precision == other.precision && + scale == other.scale end alias eql? == def hash - attributes_for_hash.hash + SqlTypeMetadata.hash ^ + sql_type.hash ^ + type.hash ^ + limit.hash ^ + precision.hash >> 1 ^ + scale.hash >> 2 end - - protected - - def attributes_for_hash - [self.class, sql_type, type, limit, precision, scale] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb new file mode 100644 index 0000000000..46ce1a15b5 --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module DatabaseStatements + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :pragma, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + + def execute(sql, name = nil) #:nodoc: + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute(sql) + end + end + end + + def exec_query(sql, name = nil, binds = [], prepare: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + type_casted_binds = type_casted_binds(binds) + + log(sql, name, binds, type_casted_binds) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + # Don't cache statements if they are not prepared + unless prepare + stmt = @connection.prepare(sql) + begin + cols = stmt.columns + unless without_prepared_statement?(binds) + stmt.bind_params(type_casted_binds) + end + records = stmt.to_a + ensure + stmt.close + end + else + stmt = @statements[sql] ||= @connection.prepare(sql) + cols = stmt.columns + stmt.reset! + stmt.bind_params(type_casted_binds) + records = stmt.to_a + end + + ActiveRecord::Result.new(cols, records) + end + end + end + + def exec_delete(sql, name = "SQL", binds = []) + exec_query(sql, name, binds) + @connection.changes + end + alias :exec_update :exec_delete + + def begin_db_transaction #:nodoc: + log("begin transaction", nil) { @connection.transaction } + end + + def commit_db_transaction #:nodoc: + log("commit transaction", nil) { @connection.commit } + end + + def exec_rollback_db_transaction #:nodoc: + log("rollback transaction", nil) { @connection.rollback } + end + + + private + def execute_batch(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute_batch2(sql) + end + end + end + + def last_inserted_id(result) + @connection.last_insert_row_id + end + + def build_fixture_statements(fixture_set) + fixture_set.flat_map do |table_name, fixtures| + next if fixtures.empty? + fixtures.map { |fixture| build_fixture_sql([fixture], table_name) } + end.compact + end + + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "DELETE FROM #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb index 70de96326c..cb9d32a577 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb @@ -12,11 +12,16 @@ module ActiveRecord quote_column_name(attr) end + def quote_table_name(name) + @quoted_table_names[name] ||= super.gsub(".", "\".\"").freeze + end + def quote_column_name(name) - @quoted_column_names[name] ||= %Q("#{super.gsub('"', '""')}").freeze + @quoted_column_names[name] ||= %Q("#{super.gsub('"', '""')}") end def quoted_time(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 @@ -25,19 +30,19 @@ module ActiveRecord end def quoted_true - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? "1".freeze : "'t'".freeze + "1" end def unquoted_true - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? 1 : "t".freeze + 1 end def quoted_false - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? "0".freeze : "'f'".freeze + "0" end def unquoted_false - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? 0 : "f".freeze + 0 end private diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb index 58e5138e02..e48f59b4f0 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -7,7 +7,11 @@ module ActiveRecord # 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| - index_sql = query_value(<<-SQL, "SCHEMA") + # 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' @@ -17,19 +21,24 @@ module ActiveRecord WHERE name = #{quote(row['name'])} AND type = 'index' SQL - /\sWHERE\s+(?<where>.+)$/i =~ index_sql + /\bON\b\s*"?(\w+?)"?\s*\((?<expressions>.+?)\)(?:\s*WHERE\b\s*(?<where>.+))?\z/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 - } + + if columns.any?(&:nil?) # index created with an expression + columns = expressions + else + # Add info on sort order for columns (only desc order is explicitly specified, + # asc is the default) + 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 end IndexDefinition.new( @@ -40,9 +49,35 @@ module ActiveRecord where: where, orders: orders ) + end.compact + end + + def add_foreign_key(from_table, to_table, **options) + alter_table(from_table) do |definition| + to_table = strip_table_name_prefix_and_suffix(to_table) + definition.foreign_key(to_table, options) end end + def remove_foreign_key(from_table, to_table = nil, **options) + to_table ||= options[:to_table] + options = options.except(:name, :to_table) + foreign_keys = foreign_keys(from_table) + + fkey = foreign_keys.detect do |fk| + table = to_table || begin + table = options[:column].to_s.delete_suffix("_id") + Base.pluralize_table_names ? table.pluralize : table + end + table = strip_table_name_prefix_and_suffix(table) + fk_to_table = strip_table_name_prefix_and_suffix(fk.to_table) + fk_to_table == table && options.all? { |k, v| fk.options[k].to_s == v.to_s } + end || raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") + + foreign_keys.delete(fkey) + alter_table(from_table, foreign_keys) + end + def create_schema_dumper(options) SQLite3::SchemaDumper.create(self, options) end @@ -53,7 +88,7 @@ module ActiveRecord end def create_table_definition(*args) - SQLite3::TableDefinition.new(*args) + SQLite3::TableDefinition.new(self, *args) end def new_column_from_field(table_name, field) @@ -70,14 +105,14 @@ module ActiveRecord end type_metadata = fetch_type_metadata(field["type"]) - Column.new(field["name"], default, type_metadata, field["notnull"].to_i == 0, table_name, nil, field["collation"]) + Column.new(field["name"], default, type_metadata, field["notnull"].to_i == 0, collation: field["collation"]) end def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) scope[:type] ||= "'table','view'" - sql = "SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'".dup + sql = +"SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'" sql << " AND name = #{scope[:name]}" if scope[:name] sql << " AND type IN (#{scope[:type]})" sql diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index a958600446..f5f5827d04 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -4,17 +4,20 @@ require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/sqlite3/explain_pretty_printer" require "active_record/connection_adapters/sqlite3/quoting" +require "active_record/connection_adapters/sqlite3/database_statements" require "active_record/connection_adapters/sqlite3/schema_creation" require "active_record/connection_adapters/sqlite3/schema_definitions" require "active_record/connection_adapters/sqlite3/schema_dumper" require "active_record/connection_adapters/sqlite3/schema_statements" -gem "sqlite3", "~> 1.3.6" +gem "sqlite3", "~> 1.4" 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" @@ -31,11 +34,9 @@ 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] - ConnectionAdapters::SQLite3Adapter.new(db, logger, nil, config) rescue Errno::ENOENT => error if error.message.include?("No such file or directory") @@ -54,10 +55,11 @@ module ActiveRecord # # * <tt>:database</tt> - Path to the database file. class SQLite3Adapter < AbstractAdapter - ADAPTER_NAME = "SQLite".freeze + ADAPTER_NAME = "SQLite" include SQLite3::Quoting include SQLite3::SchemaStatements + include SQLite3::DatabaseStatements NATIVE_DATABASE_TYPES = { primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL", @@ -74,36 +76,25 @@ module ActiveRecord json: { name: "json" }, } - ## - # :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 + def self.represent_boolean_as_integer=(value) # :nodoc: + if value == false + raise "`.represent_boolean_as_integer=` is now always true, so make sure your application can work with it and remove this settings." + end + + ActiveSupport::Deprecation.warn( + "`.represent_boolean_as_integer=` is now always true, so setting this is deprecated and will be removed in Rails 6.1." + ) + end class StatementPool < ConnectionAdapters::StatementPool # :nodoc: private def dealloc(stmt) - stmt[:stmt].close unless stmt[:stmt].closed? + stmt.close unless stmt.closed? end end def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @active = true - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) - configure_connection end @@ -116,15 +107,19 @@ module ActiveRecord end def supports_partial_index? - sqlite_version >= "3.8.0" + true + end + + def supports_expression_index? + database_version >= "3.9.0" end def requires_reloading? true end - def supports_foreign_keys_in_create? - sqlite_version >= "3.6.19" + def supports_foreign_keys? + true end def supports_views? @@ -139,27 +134,29 @@ module ActiveRecord true end - def supports_multi_insert? - sqlite_version >= "3.7.11" + def supports_insert_on_conflict? + database_version >= "3.24.0" end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? def active? - @active + !@connection.closed? + end + + def reconnect! + super + connect if @connection.closed? end # Disconnects from the database if already connected. Otherwise, this # method does nothing. def disconnect! super - @active = false @connection.close rescue nil end - # Clears the prepared statements cache. - def clear_cache! - @statements.clear - end - def supports_index_sort_order? true end @@ -184,91 +181,34 @@ module ActiveRecord true end + def supports_lazy_transactions? + true + end + # REFERENTIAL INTEGRITY ==================================== def disable_referential_integrity # :nodoc: - old = query_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 #-- # DATABASE STATEMENTS ====================================== #++ - def explain(arel, binds = []) sql = "EXPLAIN QUERY PLAN #{to_sql(arel, binds)}" SQLite3::ExplainPrettyPrinter.new.pp(exec_query(sql, "EXPLAIN", [])) end - def exec_query(sql, name = nil, binds = [], prepare: false) - type_casted_binds = type_casted_binds(binds) - - log(sql, name, binds, type_casted_binds) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - # Don't cache statements if they are not prepared - unless prepare - stmt = @connection.prepare(sql) - begin - cols = stmt.columns - unless without_prepared_statement?(binds) - stmt.bind_params(type_casted_binds) - end - records = stmt.to_a - ensure - stmt.close - end - else - cache = @statements[sql] ||= { - stmt: @connection.prepare(sql) - } - stmt = cache[:stmt] - cols = cache[:cols] ||= stmt.columns - stmt.reset! - stmt.bind_params(type_casted_binds) - records = stmt.to_a - end - - ActiveRecord::Result.new(cols, records) - end - end - end - - def exec_delete(sql, name = "SQL", binds = []) - exec_query(sql, name, binds) - @connection.changes - end - alias :exec_update :exec_delete - - def last_inserted_id(result) - @connection.last_insert_row_id - end - - def execute(sql, name = nil) #:nodoc: - log(sql, name) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.execute(sql) - end - end - end - - def begin_db_transaction #:nodoc: - log("begin transaction", nil) { @connection.transaction } - end - - def commit_db_transaction #:nodoc: - log("commit transaction", nil) { @connection.commit } - end - - def exec_rollback_db_transaction #:nodoc: - log("rollback transaction", nil) { @connection.rollback } - end - # SCHEMA STATEMENTS ======================================== def primary_keys(table_name) # :nodoc: @@ -290,11 +230,6 @@ module ActiveRecord rename_table_indexes(table_name, new_name) end - 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 invalid_alter_table_type?(type, options) alter_table(table_name) do |definition| @@ -308,6 +243,9 @@ module ActiveRecord def remove_column(table_name, column_name, type = nil, options = {}) #:nodoc: alter_table(table_name) do |definition| definition.remove_column column_name + definition.foreign_keys.delete_if do |_, fk_options| + fk_options[:column] == column_name.to_s + end end end @@ -366,27 +304,36 @@ 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) + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") + end + + sql end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - disable_referential_integrity do - transaction(requires_new: true) do - tables_to_delete.each { |table| delete "DELETE FROM #{quote_table_name(table)}", "Fixture Delete" } + def get_database_version # :nodoc: + SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) + end - fixture_set.each do |table_name, rows| - rows.each { |row| insert_fixture(row, table_name) } - end - end + def check_version # :nodoc: + if database_version < "3.8.0" + raise "Your version of SQLite (#{database_version}) is too old. Active Record supports SQLite >= 3.8." end end private + # See https://www.sqlite.org/limits.html, + # the default value is 999 when not configured. + def bind_params_length + 999 + end + def initialize_type_map(m = type_map) super register_class_with_limit m, %r(int)i, SQLite3Integer @@ -405,14 +352,27 @@ module ActiveRecord type.to_sym == :primary_key || options[:primary_key] end - def alter_table(table_name, options = {}) + def alter_table(table_name, foreign_keys = foreign_keys(table_name), **options) altered_table_name = "a#{table_name}" - caller = lambda { |definition| yield definition if block_given? } + + caller = lambda do |definition| + rename = options[:rename] || {} + foreign_keys.each do |fk| + if column = rename[fk.options[:column]] + fk.options[:column] = column + end + to_table = strip_table_name_prefix_and_suffix(fk.to_table) + definition.foreign_key(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 @@ -442,6 +402,7 @@ module ActiveRecord primary_key: column_name == from_primary_key ) end + yield @definition if block_given? end copy_table_indexes(from, to, options[:rename] || {}) @@ -453,18 +414,18 @@ module ActiveRecord def copy_table_indexes(from, to, rename = {}) indexes(from).each do |index| name = index.name - # indexes sqlite creates for internal use start with `sqlite_` and - # don't need to be copied - next if name.starts_with?("sqlite_") if to == "a#{from}" name = "t#{name}" elsif from == "a#{to}" name = name[1..-1] end - to_column_names = columns(to).map(&:name) - columns = index.columns.map { |c| rename[c] || c }.select do |column| - to_column_names.include?(column) + columns = index.columns + if columns.is_a?(Array) + to_column_names = columns(to).map(&:name) + columns = columns.map { |c| rename[c] || c }.select do |column| + to_column_names.include?(column) + end end unless columns.empty? @@ -490,22 +451,18 @@ module ActiveRecord SELECT #{quoted_from_columns} FROM #{quote_table_name(from)}") end - def sqlite_version - @sqlite_version ||= SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) - end - - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) case exception.message # SQLite 3.8.2 returns a newly formatted error message: # UNIQUE constraint failed: *table_name*.*column_name* # Older versions of SQLite return: # column *column_name* is not unique when /column(s)? .* (is|are) not unique/, /UNIQUE constraint failed: .*/ - RecordNotUnique.new(message) + RecordNotUnique.new(message, sql: sql, binds: binds) when /.* may not be NULL/, /NOT NULL constraint failed: .*/ - NotNullViolation.new(message) + NotNullViolation.new(message, sql: sql, binds: binds) when /FOREIGN KEY constraint failed/i - InvalidForeignKey.new(message) + InvalidForeignKey.new(message, sql: sql, binds: binds) else super end @@ -515,7 +472,7 @@ module ActiveRecord def table_structure_with_collation(table_name, basic_structure) collation_hash = {} - sql = <<-SQL + sql = <<~SQL SELECT sql FROM (SELECT * FROM sqlite_master UNION ALL SELECT * FROM sqlite_temp_master) @@ -548,7 +505,7 @@ module ActiveRecord column end else - basic_structure.to_hash + basic_structure.to_a end end @@ -556,7 +513,21 @@ module ActiveRecord Arel::Visitors::SQLite.new(self) end + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + + def connect + @connection = ::SQLite3::Database.new( + @config[:database].to_s, + @config.merge(results_as_hash: true) + ) + configure_connection + end + def configure_connection + @connection.busy_timeout(self.class.type_cast_config_to_integer(@config[:timeout])) if @config[:timeout] + execute("PRAGMA foreign_keys = ON", "SCHEMA") end diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index ee0e651912..53069cd899 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -46,45 +46,143 @@ module ActiveRecord # # The exceptions AdapterNotSpecified, AdapterNotFound and +ArgumentError+ # may be returned on an error. - def establish_connection(config = nil) - raise "Anonymous class is not allowed." unless name + def establish_connection(config_or_env = nil) + config_hash = resolve_config_for_connection(config_or_env) + connection_handler.establish_connection(config_hash) + end - config ||= DEFAULT_ENV.call.to_sym - spec_name = self == Base ? "primary" : name - self.connection_specification_name = spec_name + # Connects a model to the databases specified. The +database+ keyword + # takes a hash consisting of a +role+ and a +database_key+. + # + # This will create a connection handler for switching between connections, + # look up the config hash using the +database_key+ and finally + # establishes a connection to that config. + # + # class AnimalsModel < ApplicationRecord + # self.abstract_class = true + # + # connects_to database: { writing: :primary, reading: :primary_replica } + # end + # + # Returns an array of established connections. + def connects_to(database: {}) + connections = [] - resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(Base.configurations) - spec = resolver.resolve(config).symbolize_keys - spec[:name] = spec_name + database.each do |role, database_key| + config_hash = resolve_config_for_connection(database_key) + handler = lookup_connection_handler(role.to_sym) - # use the primary config if a config is not passed in and - # it's a three tier config - spec = spec[spec_name.to_sym] if spec[spec_name.to_sym] + connections << handler.establish_connection(config_hash) + end - connection_handler.establish_connection(spec) + connections end - class MergeAndResolveDefaultUrlConfig # :nodoc: - def initialize(raw_configurations) - @raw_config = raw_configurations.dup - @env = DEFAULT_ENV.call.to_s - end + # Connects to a database or role (ex writing, reading, or another + # custom role) for the duration of the block. + # + # If a role is passed, Active Record will look up the connection + # based on the requested role: + # + # ActiveRecord::Base.connected_to(role: :writing) do + # Dog.create! # creates dog using dog connection + # end + # + # ActiveRecord::Base.connected_to(role: :reading) do + # Dog.create! # throws exception because we're on a replica + # end + # + # ActiveRecord::Base.connected_to(role: :unknown_ode) do + # # raises exception due to non-existent role + # end + # + # For cases where you may want to connect to a database outside of the model, + # you can use +connected_to+ with a +database+ argument. The +database+ argument + # expects a symbol that corresponds to the database key in your config. + # + # This will connect to a new database for the queries inside the block. + # + # ActiveRecord::Base.connected_to(database: :animals_slow_replica) do + # Dog.run_a_long_query # runs a long query while connected to the +animals_slow_replica+ + # end + def connected_to(database: nil, role: nil, &blk) + if database && role + raise ArgumentError, "connected_to can only accept a `database` or a `role` argument, but not both arguments." + elsif database + if database.is_a?(Hash) + role, database = database.first + role = role.to_sym + else + role = database.to_sym + end + + config_hash = resolve_config_for_connection(database) + handler = lookup_connection_handler(role) - # Returns fully resolved connection hashes. - # Merges connection information from `ENV['DATABASE_URL']` if available. - def resolve - ConnectionAdapters::ConnectionSpecification::Resolver.new(config).resolve_all + with_handler(role) do + handler.establish_connection(config_hash) + yield + end + elsif role + with_handler(role.to_sym, &blk) + else + raise ArgumentError, "must provide a `database` or a `role`." end + end + + # Returns true if role is the current connected role. + # + # ActiveRecord::Base.connected_to(role: :writing) do + # ActiveRecord::Base.connected_to?(role: :writing) #=> true + # ActiveRecord::Base.connected_to?(role: :reading) #=> false + # end + def connected_to?(role:) + current_role == role.to_sym + end + + # Returns the symbol representing the current connected role. + # + # ActiveRecord::Base.connected_to(role: :writing) do + # ActiveRecord::Base.current_role #=> :writing + # end + # + # ActiveRecord::Base.connected_to(role: :reading) do + # ActiveRecord::Base.current_role #=> :reading + # end + def current_role + connection_handlers.key(connection_handler) + end - private - def config - @raw_config.dup.tap do |cfg| - if url = ENV["DATABASE_URL"] - cfg[@env] ||= {} - cfg[@env]["url"] ||= url - end - end + def lookup_connection_handler(handler_key) # :nodoc: + connection_handlers[handler_key] ||= ActiveRecord::ConnectionAdapters::ConnectionHandler.new + end + + def with_handler(handler_key, &blk) # :nodoc: + handler = lookup_connection_handler(handler_key) + swap_connection_handler(handler, &blk) + end + + def resolve_config_for_connection(config_or_env) # :nodoc: + raise "Anonymous class is not allowed." unless 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) + config_hash = resolver.resolve(config_or_env, pool_name).symbolize_keys + config_hash[:name] = pool_name + + config_hash + end + + # Clears the query cache for all connections associated with the current thread. + def clear_query_caches_for_current_thread + ActiveRecord::Base.connection_handlers.each_value do |handler| + handler.connection_pool_list.each do |pool| + pool.connection.clear_query_cache if pool.active_connection? end + end end # Returns the connection currently associated with the class. This can @@ -145,5 +243,14 @@ module ActiveRecord delegate :clear_active_connections!, :clear_reloadable_connections!, :clear_all_connections!, :flush_idle_connections!, to: :connection_handler + + private + + def swap_connection_handler(handler, &blk) # :nodoc: + old_handler, ActiveRecord::Base.connection_handler = ActiveRecord::Base.connection_handler, handler + yield + ensure + ActiveRecord::Base.connection_handler = old_handler + end end end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index e1a0b2ecf8..eb4b48bc37 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -2,6 +2,7 @@ require "active_support/core_ext/hash/indifferent_access" require "active_support/core_ext/string/filters" +require "active_support/parameter_filter" require "concurrent/map" module ActiveRecord @@ -26,7 +27,7 @@ module ActiveRecord ## # 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... # @@ -40,22 +41,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 @@ -99,11 +96,12 @@ module ActiveRecord ## # :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, default: true + mattr_accessor :database_selector, instance_writer: false ## # :singleton-method: # Specifies which database schemas to dump when calling db:structure:dump. @@ -125,25 +123,28 @@ module ActiveRecord mattr_accessor :belongs_to_required_by_default, instance_accessor: false + mattr_accessor :connection_handlers, instance_accessor: false, default: {} + + mattr_accessor :writing_role, instance_accessor: false, default: :writing + + mattr_accessor :reading_role, instance_accessor: false, default: :reading + class_attribute :default_connection_handler, instance_writer: false + self.filter_attributes = [] + def self.connection_handler - ActiveRecord::RuntimeRegistry.connection_handler || default_connection_handler + Thread.current.thread_variable_get("ar_connection_handler") || default_connection_handler end def self.connection_handler=(handler) - ActiveRecord::RuntimeRegistry.connection_handler = handler + Thread.current.thread_variable_set("ar_connection_handler", handler) end self.default_connection_handler = ConnectionAdapters::ConnectionHandler.new end - module ClassMethods # :nodoc: - def allocate - define_attribute_methods - super - end - + module ClassMethods def initialize_find_by_cache # :nodoc: @find_by_statement_cache = { true => Concurrent::Map.new, false => Concurrent::Map.new } end @@ -160,7 +161,7 @@ module ActiveRecord return super if block_given? || primary_key.nil? || scope_attributes? || - columns_hash.include?(inheritance_column) + columns_hash.key?(inheritance_column) && !base_class? id = ids.first @@ -172,19 +173,17 @@ module ActiveRecord where(key => params.bind).limit(1) } - record = statement.execute([id], 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) end record - rescue ::RangeError - raise RecordNotFound.new("Couldn't find #{name} with an out of range value for '#{primary_key}'", - name, primary_key) end def find_by(*args) # :nodoc: - return super if scope_attributes? || reflect_on_all_aggregations.any? + return super if scope_attributes? || reflect_on_all_aggregations.any? || + columns_hash.key?(inheritance_column) && !base_class? hash = args.first @@ -204,11 +203,9 @@ module ActiveRecord where(wheres).limit(1) } begin - statement.execute(hash.values, connection).first + statement.execute(hash.values, connection)&.first rescue TypeError raise ActiveRecord::StatementInvalid - rescue ::RangeError - nil end end @@ -220,7 +217,7 @@ module ActiveRecord generated_association_methods end - def generated_association_methods + def generated_association_methods # :nodoc: @generated_association_methods ||= begin mod = const_set(:GeneratedAssociationMethods, Module.new) private_constant :GeneratedAssociationMethods @@ -230,8 +227,20 @@ module ActiveRecord end end + # Returns columns which shouldn't be exposed while calling +#inspect+. + def filter_attributes + if defined?(@filter_attributes) + @filter_attributes + else + superclass.filter_attributes + end + end + + # Specifies columns which shouldn't be exposed while calling +#inspect+. + attr_writer :filter_attributes + # Returns a string like 'Post(id:integer, title:string, body:text)' - def inspect + def inspect # :nodoc: if self == Base super elsif abstract_class? @@ -247,7 +256,7 @@ module ActiveRecord end # Overwrite the default class equality method to provide support for decorated models. - def ===(object) + def ===(object) # :nodoc: object.is_a?(self) end @@ -273,6 +282,10 @@ module ActiveRecord TypeCaster::Map.new(self) end + def _internal? # :nodoc: + false + end + private def cached_find_by_statement(key, &block) @@ -331,13 +344,21 @@ module ActiveRecord # post = Post.allocate # post.init_with(coder) # post.title # => 'hello world' - def init_with(coder) + def init_with(coder, &block) coder = LegacyYamlAdapter.convert(self.class, coder) - @attributes = self.class.yaml_encoder.decode(coder) + attributes = self.class.yaml_encoder.decode(coder) + init_with_attributes(attributes, coder["new_record"], &block) + end + ## + # Initialize an empty model object from +attributes+. + # +attributes+ should be an attributes object, and unlike the + # `initialize` method, no assignment calls are made per attribute. + def init_with_attributes(attributes, new_record = false) # :nodoc: init_internals - @new_record = coder["new_record"] + @new_record = new_record + @attributes = attributes self.class.define_attribute_methods @@ -457,6 +478,14 @@ module ActiveRecord end end + def present? # :nodoc: + true + end + + def blank? # :nodoc: + false + end + # Returns +true+ if the record is read only. Records loaded through joins with piggy-back # attributes will be marked as read only since they cannot be saved. def readonly? @@ -479,7 +508,14 @@ module ActiveRecord inspection = if defined?(@attributes) && @attributes self.class.attribute_names.collect do |name| if has_attribute?(name) - "#{name}: #{attribute_for_inspect(name)}" + attr = _read_attribute(name) + value = if attr.nil? + attr.inspect + else + attr = format_for_inspect(attr) + inspection_filter.filter_param(name, attr) + end + "#{name}: #{value}" end end.compact.join(", ") else @@ -495,15 +531,16 @@ module ActiveRecord return super if custom_inspect_method_defined? pp.object_address_group(self) do if defined?(@attributes) && @attributes - column_names = self.class.column_names.select { |name| has_attribute?(name) || new_record? } - pp.seplist(column_names, proc { pp.text "," }) do |column_name| - column_value = read_attribute(column_name) + attr_names = self.class.attribute_names.select { |name| has_attribute?(name) } + pp.seplist(attr_names, proc { pp.text "," }) do |attr_name| pp.breakable " " pp.group(1) do - pp.text column_name + pp.text attr_name pp.text ":" pp.breakable - pp.pp column_value + value = _read_attribute(attr_name) + value = inspection_filter.filter_param(attr_name, value) unless value.nil? + pp.pp value end end else @@ -554,5 +591,15 @@ module ActiveRecord def custom_inspect_method_defined? self.class.instance_method(:inspect).owner != ActiveRecord::Base.instance_method(:inspect).owner end + + def inspection_filter + @inspection_filter ||= begin + mask = DelegateClass(::String).new(ActiveSupport::ParameterFilter::FILTERED) + def mask.pretty_print(pp) + pp.text __getobj__ + end + ActiveSupport::ParameterFilter.new(self.class.filter_attributes, mask: mask) + end + end end end diff --git a/activerecord/lib/active_record/counter_cache.rb b/activerecord/lib/active_record/counter_cache.rb index ee4f818cbf..27c1b7a311 100644 --- a/activerecord/lib/active_record/counter_cache.rb +++ b/activerecord/lib/active_record/counter_cache.rb @@ -47,8 +47,12 @@ 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 @@ -68,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 # @@ -98,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. @@ -165,24 +156,14 @@ 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 - end + association.increment_counters end id @@ -195,9 +176,7 @@ module ActiveRecord each_counter_cached_associations do |association| foreign_key = association.reflection.foreign_key.to_sym unless destroyed_by_association && destroyed_by_association.foreign_key.to_sym == foreign_key - if send(association.reflection.name) - association.decrement_counters - end + association.decrement_counters end end end diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index 09aef62753..44b5cfc738 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -1,63 +1,204 @@ # 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 - module DatabaseConfigurations # :nodoc: - class DatabaseConfig - attr_reader :env_name, :spec_name, :config + # 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 + delegate :any?, to: :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 <tt>include_replicas: true</tt>. + # + # If a spec name is provided a single DatabaseConfig object will be + # returned, otherwise an array of DatabaseConfig objects will be + # returned that corresponds with the environment and type requested. + # + # ==== Options + # + # * <tt>env_name:</tt> The environment name. Defaults to +nil+ which will collect + # configs for all environments. + # * <tt>spec_name:</tt> The specification name (i.e. primary, animals, etc.). Defaults + # to +nil+. + # * <tt>include_replicas:</tt> Determines whether to include replicas in + # the returned list. Most of the time we're only iterating over the write + # connection (i.e. migrations don't need to run for the write and read connection). + # Defaults to +false+. + def configs_for(env_name: nil, spec_name: nil, include_replicas: false) + configs = env_with_configs(env_name) - def initialize(env_name, spec_name, config) - @env_name = env_name - @spec_name = spec_name - @config = config + 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 - # Selects the config for the specified environment and specification name + # 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. # - # For example if passed :development, and :animals it will select the database - # under the :development and :animals configuration level - def self.config_for_env_and_spec(environment, specification_name, configs = ActiveRecord::Base.configurations) # :nodoc: - configs_for(environment, configs).find do |db_config| - db_config.spec_name == specification_name + # { 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 - # Collects the configs for the environment passed in. + # Checks if the application's configurations are empty. # - # If a block is given returns the specification name and configuration - # otherwise returns an array of DatabaseConfig structs for the environment. - def self.configs_for(env, configs = ActiveRecord::Base.configurations, &blk) # :nodoc: - env_with_configs = db_configs(configs).select do |db_config| - db_config.env_name == env + # 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 - if block_given? - env_with_configs.each do |env_with_config| - yield env_with_config.spec_name, env_with_config.config + def build_configs(configs) + return configs.configurations if configs.is_a?(DatabaseConfigurations) + return configs if configs.is_a?(Array) + + build_db_config = configs.each_pair.flat_map do |env_name, config| + walk_configs(env_name.to_s, "primary", config) + end.flatten.compact + + if url = ENV["DATABASE_URL"] + build_url_config(url, build_db_config) + else + build_db_config end - else - env_with_configs end - end - # Given an env, spec and config creates DatabaseConfig structs with - # each attribute set. - def self.walk_configs(env_name, spec_name, config) # :nodoc: - if config["database"] || config["url"] || env_name == "default" - DatabaseConfig.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) + 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.stringify_keys) end end - end - # Walks all the configs passed in and returns an array - # of DatabaseConfig structs for each configuration. - def self.db_configs(configs = ActiveRecord::Base.configurations) # :nodoc: - configs.each_pair.flat_map do |env_name, config| - walk_configs(env_name, "primary", config) + def build_db_config_from_string(env_name, spec_name, config) + 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 + + def build_db_config_from_hash(env_name, spec_name, config) + if config.has_key?("url") + 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(config.env_name, 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) + case method + when :each, :first + throw_getter_deprecation(method) + configurations.send(method, *args, &blk) + when :fetch + throw_getter_deprecation(method) + configs_for(env_name: args.first) + when :values + throw_getter_deprecation(method) + configurations.map(&:config) + when :[]= + throw_setter_deprecation(method) + + env_name = args[0] + config = args[1] + + remaining_configs = configurations.reject { |db_config| db_config.env_name == env_name } + new_config = build_configs(env_name => config) + new_configs = remaining_configs + new_config + + ActiveRecord::Base.configurations = new_configs + else + raise NotImplementedError, "`ActiveRecord::Base.configurations` in Rails 6 now returns an object instead of a hash. The `#{method}` method is not supported. Please use `configs_for` or consult the documentation for supported methods." + end + end + + def throw_setter_deprecation(method) + ActiveSupport::Deprecation.warn("Setting `ActiveRecord::Base.configurations` with `#{method}` is deprecated. Use `ActiveRecord::Base.configurations=` directly to set the configurations instead.") + end + + def throw_getter_deprecation(method) + ActiveSupport::Deprecation.warn("`ActiveRecord::Base.configurations` no longer returns a hash. Methods that act on the hash like `#{method}` are deprecated and will be removed in Rails 6.1. Use the `configs_for` method to collect and iterate over the database configurations.") end - end end 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..adc37cc439 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/database_config.rb @@ -0,0 +1,37 @@ +# 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 migrations_paths + 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..e31ff09391 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -0,0 +1,50 @@ +# 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 + # + # * <tt>:env_name</tt> - The Rails environment, i.e. "development". + # * <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # * <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. + class HashConfig < DatabaseConfig + attr_reader :config + + 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 + + # The migrations paths for a database configuration. If the + # +migrations_paths+ key is present in the config, +migrations_paths+ + # will return its value. + def migrations_paths + config["migrations_paths"] + 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..e2d30ae416 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/url_config.rb @@ -0,0 +1,79 @@ +# 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 + # + # * <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 + + # The migrations paths for a database configuration. If the + # +migrations_paths+ key is present in the config, +migrations_paths+ + # will return its value. + def migrations_paths + config["migrations_paths"] + end + + private + + def build_url_hash(url) + if url.nil? || /^jdbc:/.match?(url) + { "url" => url } + else + ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(url).to_hash + end + end + + def build_config(original_config, url) + hash = build_url_hash(url) + + 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/enum.rb b/activerecord/lib/active_record/enum.rb index 23ecb24542..8077630aeb 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -31,7 +31,9 @@ module ActiveRecord # as well. With the above example: # # Conversation.active + # Conversation.not_active # Conversation.archived + # Conversation.not_archived # # Of course, you can also query them directly if the scopes don't fit your # needs: @@ -149,14 +151,16 @@ module ActiveRecord klass = self enum_prefix = definitions.delete(:_prefix) enum_suffix = definitions.delete(:_suffix) + enum_scopes = definitions.delete(:_scopes) definitions.each do |name, values| + assert_valid_enum_definition_values(values) # statuses = { } enum_values = ActiveSupport::HashWithIndifferentAccess.new name = name.to_s # def self.statuses() statuses end detect_enum_conflict!(name, name.pluralize, true) - singleton_class.send(:define_method, name.pluralize) { enum_values } + singleton_class.define_method(name.pluralize) { enum_values } defined_enums[name] = enum_values detect_enum_conflict!(name, name) @@ -194,10 +198,17 @@ module ActiveRecord define_method("#{value_method_name}!") { update!(attr => value) } # scope :active, -> { where(status: 0) } - klass.send(:detect_enum_conflict!, name, value_method_name, true) - klass.scope value_method_name, -> { where(attr => value) } + # scope :not_active, -> { where.not(status: 0) } + if enum_scopes != false + klass.send(:detect_enum_conflict!, name, value_method_name, true) + klass.scope value_method_name, -> { where(attr => value) } + + klass.send(:detect_enum_conflict!, name, "not_#{value_method_name}", true) + klass.scope "not_#{value_method_name}", -> { where.not(attr => value) } + end end end + enum_values.freeze end end @@ -210,10 +221,24 @@ module ActiveRecord end end + def assert_valid_enum_definition_values(values) + unless values.is_a?(Hash) || values.all? { |v| v.is_a?(Symbol) } || values.all? { |v| v.is_a?(String) } + error_message = <<~MSG + Enum values #{values} must be either a hash, an array of symbols, or an array of strings. + MSG + raise ArgumentError, error_message + end + + if values.is_a?(Hash) && values.keys.any?(&:blank?) || values.is_a?(Array) && values.any?(&:blank?) + raise ArgumentError, "Enum label name must not be blank." + end + end + ENUM_CONFLICT_MESSAGE = \ "You tried to define an enum named \"%{enum}\" on the model \"%{klass}\", but " \ "this will generate a %{type} method \"%{method}\", which is already defined " \ "by %{source}." + private_constant :ENUM_CONFLICT_MESSAGE def detect_enum_conflict!(enum_name, method_name, klass_method = false) if klass_method && dangerous_class_method?(method_name) diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index c2a180c939..60cf9818c1 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -49,6 +49,10 @@ module ActiveRecord class ConnectionNotEstablished < ActiveRecordError end + # Raised when a write to the database is attempted on a read only connection. + class ReadOnlyError < ActiveRecordError + end + # Raised when Active Record cannot find a record by given id or set of ids. class RecordNotFound < ActiveRecordError attr_reader :model, :primary_key, :id @@ -64,7 +68,7 @@ module ActiveRecord # Raised by {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] and # {ActiveRecord::Base.create!}[rdoc-ref:Persistence::ClassMethods#create!] - # methods when a record is invalid and can not be saved. + # methods when a record is invalid and cannot be saved. class RecordNotSaved < ActiveRecordError attr_reader :record @@ -97,9 +101,13 @@ module ActiveRecord # # Wraps the underlying database error as +cause+. class StatementInvalid < ActiveRecordError - def initialize(message = nil) + def initialize(message = nil, sql: nil, binds: nil) super(message || $!.try(:message)) + @sql = sql + @binds = binds end + + attr_reader :sql, :binds end # Defunct wrapper class kept for compatibility. @@ -111,22 +119,33 @@ module ActiveRecord 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 # Raised when a foreign key constraint cannot be added because the column type does not match the referenced column type. class MismatchedForeignKey < StatementInvalid - def initialize(adapter = nil, message: nil, table: nil, foreign_key: nil, target_table: nil, primary_key: nil) - @adapter = adapter + def initialize( + message: nil, + sql: nil, + binds: nil, + table: nil, + foreign_key: nil, + target_table: nil, + primary_key: nil, + primary_key_column: nil + ) if table - msg = +<<~EOM - Column `#{foreign_key}` on table `#{table}` has a type of `#{column_type(table, foreign_key)}`. - This does not match column `#{primary_key}` on `#{target_table}`, which has type `#{column_type(target_table, primary_key)}`. - To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :integer. (For example `t.integer #{foreign_key}`). + type = primary_key_column.bigint? ? :bigint : primary_key_column.type + msg = <<~EOM.squish + Column `#{foreign_key}` on table `#{table}` does not match column `#{primary_key}` on `#{target_table}`, + which has type `#{primary_key_column.sql_type}`. + To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :#{type}. + (For example `t.#{type} :#{foreign_key}`). EOM else - msg = +<<~EOM + msg = <<~EOM.squish There is a mismatch between the foreign key and primary key column types. Verify that the foreign key column type and the primary key of the associated table match types. EOM @@ -134,13 +153,8 @@ module ActiveRecord if message msg << "\nOriginal message: #{message}" end - super(msg) + super(msg, sql: sql, binds: binds) end - - private - def column_type(table, column) - @adapter.columns(table).detect { |c| c.name == column }.sql_type - end end # Raised when a record cannot be inserted or updated because it would violate a not null constraint. diff --git a/activerecord/lib/active_record/explain.rb b/activerecord/lib/active_record/explain.rb index 7ccb938888..919e96cd7a 100644 --- a/activerecord/lib/active_record/explain.rb +++ b/activerecord/lib/active_record/explain.rb @@ -18,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}".dup + msg = +"EXPLAIN for: #{sql}" unless binds.empty? msg << " " msg << binds.map { |attr| render_bind(attr) }.inspect diff --git a/activerecord/lib/active_record/fixture_set/model_metadata.rb b/activerecord/lib/active_record/fixture_set/model_metadata.rb new file mode 100644 index 0000000000..fb23df6f45 --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/model_metadata.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActiveRecord + class FixtureSet + class ModelMetadata # :nodoc: + def initialize(model_class) + @model_class = model_class + end + + def primary_key_name + @primary_key_name ||= @model_class && @model_class.primary_key + end + + def primary_key_type + @primary_key_type ||= @model_class && @model_class.type_for_attribute(@model_class.primary_key).type + end + + def has_primary_key_column? + @has_primary_key_column ||= primary_key_name && + @model_class.columns.any? { |col| col.name == primary_key_name } + end + + def timestamp_column_names + @timestamp_column_names ||= + %w(created_at created_on updated_at updated_on) & @model_class.column_names + end + + def inheritance_column_name + @inheritance_column_name ||= @model_class && @model_class.inheritance_column + end + end + end +end diff --git a/activerecord/lib/active_record/fixture_set/render_context.rb b/activerecord/lib/active_record/fixture_set/render_context.rb new file mode 100644 index 0000000000..c90b5343dc --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/render_context.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# NOTE: This class has to be defined in compact style in +# order for rendering context subclassing to work correctly. +class ActiveRecord::FixtureSet::RenderContext # :nodoc: + def self.create_subclass + Class.new(ActiveRecord::FixtureSet.context_class) do + 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/fixture_set/table_row.rb b/activerecord/lib/active_record/fixture_set/table_row.rb new file mode 100644 index 0000000000..cb4726f1ee --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/table_row.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module ActiveRecord + class FixtureSet + class TableRow # :nodoc: + class ReflectionProxy # :nodoc: + def initialize(association) + @association = association + end + + def join_table + @association.join_table + end + + def name + @association.name + end + + def primary_key_type + @association.klass.type_for_attribute(@association.klass.primary_key).type + end + end + + class HasManyThroughProxy < ReflectionProxy # :nodoc: + def rhs_key + @association.foreign_key + end + + def lhs_key + @association.through_reflection.foreign_key + end + + def join_table + @association.through_reflection.table_name + end + end + + def initialize(fixture, table_rows:, label:, now:) + @table_rows = table_rows + @label = label + @now = now + @row = fixture.to_hash + fill_row_model_attributes + end + + def to_hash + @row + end + + private + + def model_metadata + @table_rows.model_metadata + end + + def model_class + @table_rows.model_class + end + + def fill_row_model_attributes + return unless model_class + fill_timestamps + interpolate_label + generate_primary_key + resolve_enums + resolve_sti_reflections + end + + def reflection_class + @reflection_class ||= if @row.include?(model_metadata.inheritance_column_name) + @row[model_metadata.inheritance_column_name].constantize rescue model_class + else + model_class + end + end + + def fill_timestamps + # fill in timestamp columns if they aren't specified and the model is set to record_timestamps + if model_class.record_timestamps + model_metadata.timestamp_column_names.each do |c_name| + @row[c_name] = @now unless @row.key?(c_name) + end + end + end + + def interpolate_label + # interpolate the fixture label + @row.each do |key, value| + @row[key] = value.gsub("$LABEL", @label.to_s) if value.is_a?(String) + end + end + + def generate_primary_key + # generate a primary key if necessary + if model_metadata.has_primary_key_column? && !@row.include?(model_metadata.primary_key_name) + @row[model_metadata.primary_key_name] = ActiveRecord::FixtureSet.identify( + @label, model_metadata.primary_key_type + ) + end + end + + def resolve_enums + model_class.defined_enums.each do |name, values| + if @row.include?(name) + @row[name] = values.fetch(@row[name], @row[name]) + end + end + end + + def resolve_sti_reflections + # If STI is used, find the correct subclass for association reflection + reflection_class._reflections.each_value do |association| + case association.macro + when :belongs_to + # Do not replace association name with association foreign key if they are named the same + fk_name = (association.options[:foreign_key] || "#{association.name}_id").to_s + + if association.name.to_s != fk_name && value = @row.delete(association.name.to_s) + if association.polymorphic? && value.sub!(/\s*\(([^\)]*)\)\s*$/, "") + # support polymorphic belongs_to as "label (Type)" + @row[association.foreign_type] = $1 + end + + fk_type = reflection_class.type_for_attribute(fk_name).type + @row[fk_name] = ActiveRecord::FixtureSet.identify(value, fk_type) + end + when :has_many + if association.options[:through] + add_join_records(HasManyThroughProxy.new(association)) + end + end + end + end + + def add_join_records(association) + # This is the case when the join table has no fixtures file + if (targets = @row.delete(association.name.to_s)) + table_name = association.join_table + column_type = association.primary_key_type + lhs_key = association.lhs_key + rhs_key = association.rhs_key + + targets = targets.is_a?(Array) ? targets : targets.split(/\s*,\s*/) + joins = targets.map do |target| + { lhs_key => @row[model_metadata.primary_key_name], + rhs_key => ActiveRecord::FixtureSet.identify(target, column_type) } + end + @table_rows.tables[table_name].concat(joins) + end + end + end + end +end diff --git a/activerecord/lib/active_record/fixture_set/table_rows.rb b/activerecord/lib/active_record/fixture_set/table_rows.rb new file mode 100644 index 0000000000..23814b6cb5 --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/table_rows.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "active_record/fixture_set/table_row" +require "active_record/fixture_set/model_metadata" + +module ActiveRecord + class FixtureSet + class TableRows # :nodoc: + def initialize(table_name, model_class:, fixtures:, config:) + @model_class = model_class + + # track any join tables we need to insert later + @tables = Hash.new { |h, table| h[table] = [] } + + # ensure this table is loaded before any HABTM associations + @tables[table_name] = nil + + build_table_rows_from(table_name, fixtures, config) + end + + attr_reader :tables, :model_class + + def to_hash + @tables.transform_values { |rows| rows.map(&:to_hash) } + end + + def model_metadata + @model_metadata ||= ModelMetadata.new(model_class) + end + + private + + def build_table_rows_from(table_name, fixtures, config) + now = config.default_timezone == :utc ? Time.now.utc : Time.now + + @tables[table_name] = fixtures.map do |label, fixture| + TableRow.new( + fixture, + table_rows: self, + label: label, + now: now, + ) + end + end + end + end +end diff --git a/activerecord/lib/active_record/fixtures.rb b/activerecord/lib/active_record/fixtures.rb index 8f022ff7a7..327121a2a2 100644 --- a/activerecord/lib/active_record/fixtures.rb +++ b/activerecord/lib/active_record/fixtures.rb @@ -7,6 +7,9 @@ require "set" require "active_support/dependencies" require "active_support/core_ext/digest/uuid" require "active_record/fixture_set/file" +require "active_record/fixture_set/render_context" +require "active_record/fixture_set/table_rows" +require "active_record/test_fixtures" require "active_record/errors" module ActiveRecord @@ -179,8 +182,8 @@ module ActiveRecord # 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 @@ -440,60 +443,6 @@ module ActiveRecord @@all_cached_fixtures = Hash.new { |h, k| h[k] = {} } - def self.default_fixture_model_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: - config.pluralize_table_names ? - fixture_set_name.singularize.camelize : - fixture_set_name.camelize - end - - def self.default_fixture_table_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: - "#{ config.table_name_prefix }"\ - "#{ fixture_set_name.tr('/', '_') }"\ - "#{ config.table_name_suffix }".to_sym - end - - def self.reset_cache - @@all_cached_fixtures.clear - end - - def self.cache_for_connection(connection) - @@all_cached_fixtures[connection] - end - - def self.fixture_is_cached?(connection, table_name) - cache_for_connection(connection)[table_name] - end - - def self.cached_fixtures(connection, keys_to_fetch = nil) - if keys_to_fetch - cache_for_connection(connection).values_at(*keys_to_fetch) - else - cache_for_connection(connection).values - end - end - - def self.cache_fixtures(connection, fixtures_map) - cache_for_connection(connection).update(fixtures_map) - end - - def self.instantiate_fixtures(object, fixture_set, load_instances = true) - if load_instances - fixture_set.each do |fixture_name, fixture| - begin - object.instance_variable_set "@#{fixture_name}", fixture.find - rescue FixtureClassNotFound - nil - end - end - end - end - - def self.instantiate_all_loaded_fixtures(object, load_instances = true) - all_loaded_fixtures.each_value do |fixture_set| - instantiate_fixtures(object, fixture_set, load_instances) - end - end - cattr_accessor :all_loaded_fixtures, default: {} class ClassCache @@ -502,14 +451,16 @@ module ActiveRecord @config = config # Remove string values that aren't constants or subclasses of AR - @class_names.delete_if { |klass_name, klass| !insert_class(@class_names, klass_name, klass) } + @class_names.delete_if do |klass_name, klass| + !insert_class(@class_names, klass_name, klass) + end end def [](fs_name) - @class_names.fetch(fs_name) { + @class_names.fetch(fs_name) do klass = default_fixture_model(fs_name, @config).safe_constantize insert_class(@class_names, fs_name, klass) - } + end end private @@ -528,76 +479,146 @@ module ActiveRecord end end - def self.create_fixtures(fixtures_directory, fixture_set_names, class_names = {}, config = ActiveRecord::Base) - fixture_set_names = Array(fixture_set_names).map(&:to_s) - class_names = ClassCache.new class_names, config + class << self + def default_fixture_model_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: + config.pluralize_table_names ? + fixture_set_name.singularize.camelize : + fixture_set_name.camelize + end - # FIXME: Apparently JK uses this. - connection = block_given? ? yield : ActiveRecord::Base.connection + def default_fixture_table_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: + "#{ config.table_name_prefix }"\ + "#{ fixture_set_name.tr('/', '_') }"\ + "#{ config.table_name_suffix }".to_sym + end - files_to_read = fixture_set_names.reject { |fs_name| - fixture_is_cached?(connection, fs_name) - } + def reset_cache + @@all_cached_fixtures.clear + end - unless files_to_read.empty? - fixtures_map = {} + def cache_for_connection(connection) + @@all_cached_fixtures[connection] + end - 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)) + def fixture_is_cached?(connection, table_name) + cache_for_connection(connection)[table_name] + end + + def cached_fixtures(connection, keys_to_fetch = nil) + if keys_to_fetch + cache_for_connection(connection).values_at(*keys_to_fetch) + else + cache_for_connection(connection).values end + end - update_all_loaded_fixtures fixtures_map - fixture_sets_by_connection = fixture_sets.group_by { |fs| fs.model_class ? fs.model_class.connection : connection } + def cache_fixtures(connection, fixtures_map) + cache_for_connection(connection).update(fixtures_map) + end - fixture_sets_by_connection.each do |conn, set| - table_rows_for_connection = Hash.new { |h, k| h[k] = [] } + def instantiate_fixtures(object, fixture_set, load_instances = true) + return unless load_instances + fixture_set.each do |fixture_name, fixture| + object.instance_variable_set "@#{fixture_name}", fixture.find + rescue FixtureClassNotFound + nil + end + 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) + def instantiate_all_loaded_fixtures(object, load_instances = true) + all_loaded_fixtures.each_value do |fixture_set| + instantiate_fixtures(object, fixture_set, load_instances) + end + end - # 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 + def create_fixtures(fixtures_directory, fixture_set_names, class_names = {}, config = ActiveRecord::Base) + fixture_set_names = Array(fixture_set_names).map(&:to_s) + class_names = ClassCache.new class_names, config + + # FIXME: Apparently JK uses this. + connection = block_given? ? yield : ActiveRecord::Base.connection + + fixture_files_to_read = fixture_set_names.reject do |fs_name| + fixture_is_cached?(connection, fs_name) end - cache_fixtures(connection, fixtures_map) + if fixture_files_to_read.any? + fixtures_map = read_and_insert( + fixtures_directory, + fixture_files_to_read, + class_names, + connection, + ) + cache_fixtures(connection, fixtures_map) + end + cached_fixtures(connection, fixture_set_names) end - cached_fixtures(connection, fixture_set_names) - end - # Returns a consistent, platform-independent identifier for +label+. - # Integer identifiers are values less than 2^30. UUIDs are RFC 4122 version 5 SHA-1 hashes. - def self.identify(label, column_type = :integer) - if column_type == :uuid - Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s) - else - Zlib.crc32(label.to_s) % MAX_ID + # Returns a consistent, platform-independent identifier for +label+. + # Integer identifiers are values less than 2^30. UUIDs are RFC 4122 version 5 SHA-1 hashes. + def identify(label, column_type = :integer) + if column_type == :uuid + Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s) + else + Zlib.crc32(label.to_s) % MAX_ID + end end - end - # Superclass for the evaluation contexts used by ERB fixtures. - def self.context_class - @context_class ||= Class.new - end + # Superclass for the evaluation contexts used by ERB fixtures. + def context_class + @context_class ||= Class.new + end - def self.update_all_loaded_fixtures(fixtures_map) # :nodoc: - all_loaded_fixtures.update(fixtures_map) + private + + def read_and_insert(fixtures_directory, fixture_files, class_names, connection) # :nodoc: + fixtures_map = {} + fixture_sets = fixture_files.map do |fixture_set_name| + klass = class_names[fixture_set_name] + fixtures_map[fixture_set_name] = new( # ActiveRecord::FixtureSet.new + nil, + fixture_set_name, + klass, + ::File.join(fixtures_directory, fixture_set_name) + ) + end + update_all_loaded_fixtures(fixtures_map) + + insert(fixture_sets, connection) + + fixtures_map + end + + def insert(fixture_sets, connection) # :nodoc: + fixture_sets_by_connection = fixture_sets.group_by do |fixture_set| + fixture_set.model_class&.connection || connection + end + + fixture_sets_by_connection.each do |conn, set| + table_rows_for_connection = Hash.new { |h, k| h[k] = [] } + + set.each do |fixture_set| + fixture_set.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) + + # 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 + end + + def update_all_loaded_fixtures(fixtures_map) # :nodoc: + all_loaded_fixtures.update(fixtures_map) + end end attr_reader :table_name, :name, :fixtures, :model_class, :config - def initialize(connection, name, class_name, path, config = ActiveRecord::Base) + def initialize(_, name, class_name, path, config = ActiveRecord::Base) @name = name @path = path @config = config @@ -606,11 +627,7 @@ module ActiveRecord @fixtures = read_fixture_files(path) - @connection = connection - - @table_name = (model_class.respond_to?(:table_name) ? - model_class.table_name : - self.class.default_fixture_table_name(name, config)) + @table_name = model_class&.table_name || self.class.default_fixture_table_name(name, config) end def [](x) @@ -632,152 +649,18 @@ module ActiveRecord # Returns a hash of rows to be inserted. The key is the table, the value is # a list of rows to insert to that table. def table_rows - now = config.default_timezone == :utc ? Time.now.utc : Time.now - # allow a standard key to be used for doing defaults in YAML fixtures.delete("DEFAULTS") - # track any join tables we need to insert later - rows = Hash.new { |h, table| h[table] = [] } - - rows[table_name] = fixtures.map do |label, fixture| - row = fixture.to_hash - - if model_class - # fill in timestamp columns if they aren't specified and the model is set to record_timestamps - if model_class.record_timestamps - timestamp_column_names.each do |c_name| - row[c_name] = now unless row.key?(c_name) - end - end - - # interpolate the fixture label - row.each do |key, value| - row[key] = value.gsub("$LABEL", label.to_s) if value.is_a?(String) - end - - # generate a primary key if necessary - if has_primary_key_column? && !row.include?(primary_key_name) - row[primary_key_name] = ActiveRecord::FixtureSet.identify(label, primary_key_type) - end - - # Resolve enums - model_class.defined_enums.each do |name, values| - if row.include?(name) - row[name] = values.fetch(row[name], row[name]) - end - end - - # If STI is used, find the correct subclass for association reflection - reflection_class = - if row.include?(inheritance_column_name) - row[inheritance_column_name].constantize rescue model_class - else - model_class - end - - reflection_class._reflections.each_value do |association| - case association.macro - when :belongs_to - # Do not replace association name with association foreign key if they are named the same - fk_name = (association.options[:foreign_key] || "#{association.name}_id").to_s - - if association.name.to_s != fk_name && value = row.delete(association.name.to_s) - if association.polymorphic? && value.sub!(/\s*\(([^\)]*)\)\s*$/, "") - # support polymorphic belongs_to as "label (Type)" - row[association.foreign_type] = $1 - end - - fk_type = reflection_class.type_for_attribute(fk_name).type - row[fk_name] = ActiveRecord::FixtureSet.identify(value, fk_type) - end - when :has_many - if association.options[:through] - add_join_records(rows, row, HasManyThroughProxy.new(association)) - end - end - end - end - - row - end - rows - end - - class ReflectionProxy # :nodoc: - def initialize(association) - @association = association - end - - def join_table - @association.join_table - end - - def name - @association.name - end - - def primary_key_type - @association.klass.type_for_attribute(@association.klass.primary_key).type - end - end - - class HasManyThroughProxy < ReflectionProxy # :nodoc: - def rhs_key - @association.foreign_key - end - - def lhs_key - @association.through_reflection.foreign_key - end - - def join_table - @association.through_reflection.table_name - end + TableRows.new( + table_name, + model_class: model_class, + fixtures: fixtures, + config: config, + ).to_hash end private - def primary_key_name - @primary_key_name ||= model_class && model_class.primary_key - end - - def primary_key_type - @primary_key_type ||= model_class && model_class.type_for_attribute(model_class.primary_key).type - end - - def add_join_records(rows, row, association) - # This is the case when the join table has no fixtures file - if (targets = row.delete(association.name.to_s)) - table_name = association.join_table - column_type = association.primary_key_type - lhs_key = association.lhs_key - rhs_key = association.rhs_key - - targets = targets.is_a?(Array) ? targets : targets.split(/\s*,\s*/) - rows[table_name].concat targets.map { |target| - { lhs_key => row[primary_key_name], - rhs_key => ActiveRecord::FixtureSet.identify(target, column_type) } - } - end - end - - def has_primary_key_column? - @has_primary_key_column ||= primary_key_name && - model_class.columns.any? { |c| c.name == primary_key_name } - end - - def timestamp_column_names - @timestamp_column_names ||= - %w(created_at created_on updated_at updated_on) & column_names - end - - def inheritance_column_name - @inheritance_column_name ||= model_class && model_class.inheritance_column - end - - def column_names - @column_names ||= @connection.columns(@table_name).collect(&:name) - end def model_class=(class_name) if class_name.is_a?(Class) # TODO: Should be an AR::Base type class, or any? @@ -841,225 +724,9 @@ module ActiveRecord alias :to_hash :fixture def find - if model_class - model_class.unscoped do - model_class.find(fixture[model_class.primary_key]) - end - else - raise FixtureClassNotFound, "No class attached to find." - end - end - end -end - -module ActiveRecord - module TestFixtures - extend ActiveSupport::Concern - - def before_setup # :nodoc: - setup_fixtures - super - end - - def after_teardown # :nodoc: - super - teardown_fixtures - end - - included do - class_attribute :fixture_path, instance_writer: false - 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 - # Sets the model class for a fixture when the class name cannot be inferred from the fixture name. - # - # Examples: - # - # set_fixture_class some_fixture: SomeModel, - # 'namespaced/fixture' => Another::Model - # - # The keys must be the fixture names, that coincide with the short paths to the fixture files. - def set_fixture_class(class_names = {}) - self.fixture_class_names = fixture_class_names.merge(class_names.stringify_keys) - end - - def fixtures(*fixture_set_names) - if fixture_set_names.first == :all - fixture_set_names = Dir["#{fixture_path}/{**,*}/*.{yml}"].uniq - fixture_set_names.map! { |f| f[(fixture_path.to_s.size + 1)..-5] } - else - fixture_set_names = fixture_set_names.flatten.map(&:to_s) - end - - self.fixture_table_names |= fixture_set_names - setup_fixture_accessors(fixture_set_names) - end - - def setup_fixture_accessors(fixture_set_names = nil) - fixture_set_names = Array(fixture_set_names || fixture_table_names) - methods = Module.new do - fixture_set_names.each do |fs_name| - fs_name = fs_name.to_s - accessor_name = fs_name.tr("/", "_").to_sym - - 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] ||= {} - - instances = fixture_names.map do |f_name| - f_name = f_name.to_s if f_name.is_a?(Symbol) - @fixture_cache[fs_name].delete(f_name) if force_reload - - if @loaded_fixtures[fs_name][f_name] - @fixture_cache[fs_name][f_name] ||= @loaded_fixtures[fs_name][f_name].find - else - raise StandardError, "No fixture named '#{f_name}' found for fixture set '#{fs_name}'" - end - end - - return_single_record ? instances.first : instances - end - private accessor_name - end - end - include methods - end - - def uses_transaction(*methods) - @uses_transaction = [] unless defined?(@uses_transaction) - @uses_transaction.concat methods.map(&:to_s) - end - - def uses_transaction?(method) - @uses_transaction = [] unless defined?(@uses_transaction) - @uses_transaction.include?(method.to_s) - end - end - - def run_in_transaction? - use_transactional_tests && - !self.class.uses_transaction?(method_name) - end - - def setup_fixtures(config = ActiveRecord::Base) - if pre_loaded_fixtures && !use_transactional_tests - raise RuntimeError, "pre_loaded_fixtures requires use_transactional_tests" - end - - @fixture_cache = {} - @fixture_connections = [] - @@already_loaded_fixtures ||= {} - @connection_subscriber = nil - - # Load fixtures once and begin transaction. - if run_in_transaction? - if @@already_loaded_fixtures[self.class] - @loaded_fixtures = @@already_loaded_fixtures[self.class] - else - @loaded_fixtures = load_fixtures(config) - @@already_loaded_fixtures[self.class] = @loaded_fixtures - end - - # Begin transactions for connections already established - @fixture_connections = enlist_fixture_connections - @fixture_connections.each do |connection| - connection.begin_transaction joinable: false - connection.pool.lock_thread = true if lock_threads - end - - # When connections are established in the future, begin a transaction too - @connection_subscriber = ActiveSupport::Notifications.subscribe("!connection.active_record") do |_, _, _, _, payload| - spec_name = payload[:spec_name] if payload.key?(:spec_name) - - if spec_name - begin - connection = ActiveRecord::Base.connection_handler.retrieve_connection(spec_name) - rescue ConnectionNotEstablished - connection = nil - end - - if connection && !@fixture_connections.include?(connection) - connection.begin_transaction joinable: false - connection.pool.lock_thread = true if lock_threads - @fixture_connections << connection - end - end - end - - # Load fixtures for every test. - else - ActiveRecord::FixtureSet.reset_cache - @@already_loaded_fixtures[self.class] = nil - @loaded_fixtures = load_fixtures(config) - end - - # Instantiate fixtures for every test if requested. - instantiate_fixtures if use_instantiated_fixtures - end - - def teardown_fixtures - # Rollback changes if a transaction is active. - if run_in_transaction? - ActiveSupport::Notifications.unsubscribe(@connection_subscriber) if @connection_subscriber - @fixture_connections.each do |connection| - connection.rollback_transaction if connection.transaction_open? - connection.pool.lock_thread = false - end - @fixture_connections.clear - else - ActiveRecord::FixtureSet.reset_cache - end - - ActiveRecord::Base.clear_active_connections! - end - - def enlist_fixture_connections - ActiveRecord::Base.connection_handler.connection_pool_list.map(&:connection) - end - - private - def load_fixtures(config) - fixtures = ActiveRecord::FixtureSet.create_fixtures(fixture_path, fixture_table_names, fixture_class_names, config) - Hash[fixtures.map { |f| [f.name, f] }] - end - - def instantiate_fixtures - if pre_loaded_fixtures - raise RuntimeError, "Load fixtures before instantiating them." if ActiveRecord::FixtureSet.all_loaded_fixtures.empty? - ActiveRecord::FixtureSet.instantiate_all_loaded_fixtures(self, load_instances?) - else - raise RuntimeError, "Load fixtures before instantiating them." if @loaded_fixtures.nil? - @loaded_fixtures.each_value do |fixture_set| - ActiveRecord::FixtureSet.instantiate_fixtures(self, fixture_set, load_instances?) - end - end - end - - def load_instances? - use_instantiated_fixtures != :no_instances - end - end -end - -class ActiveRecord::FixtureSet::RenderContext # :nodoc: - def self.create_subclass - Class.new ActiveRecord::FixtureSet.context_class do - def get_binding - binding() - end - - def binary(path) - %(!!binary "#{Base64.strict_encode64(File.read(path))}") + raise FixtureClassNotFound, "No class attached to find." unless model_class + model_class.unscoped do + model_class.find(fixture[model_class.primary_key]) end end end diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 72035a986b..f77bc2e3c1 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -10,7 +10,7 @@ module ActiveRecord MAJOR = 6 MINOR = 0 TINY = 0 - PRE = "alpha" + PRE = "beta3" 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 6891c575c7..9570bc6f86 100644 --- a/activerecord/lib/active_record/inheritance.rb +++ b/activerecord/lib/active_record/inheritance.rb @@ -55,6 +55,10 @@ module ActiveRecord if has_attribute?(inheritance_column) 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? subclass = subclass_from_attributes(column_defaults) end @@ -176,7 +180,7 @@ module ActiveRecord # Returns the class type of the record using the current module as a prefix. So descendants of # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass. def compute_type(type_name) - if type_name.start_with?("::".freeze) + if type_name.start_with?("::") # If the type is prefixed with a scope operator then we assume that # the type_name is an absolute reference. ActiveSupport::Dependencies.constantize(type_name) @@ -245,7 +249,7 @@ module ActiveRecord sti_column = arel_attribute(inheritance_column, table) sti_names = ([self] + descendants).map(&:sti_name) - sti_column.in(sti_names) + predicate_builder.build(sti_column, sti_names) end # Detect the subclass from the inheritance column of attrs. If the inheritance column value diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb new file mode 100644 index 0000000000..959e5bd4d7 --- /dev/null +++ b/activerecord/lib/active_record/insert_all.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +module ActiveRecord + class InsertAll # :nodoc: + attr_reader :model, :connection, :inserts, :keys + attr_reader :on_duplicate, :returning, :unique_by + + def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil) + raise ArgumentError, "Empty list of attributes passed" if inserts.blank? + + @model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s).to_set + @on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by + + @returning = (connection.supports_insert_returning? ? primary_keys : false) if @returning.nil? + @returning = false if @returning == [] + + @unique_by = find_unique_index_for(unique_by) if unique_by + @on_duplicate = :skip if @on_duplicate == :update && updatable_columns.empty? + + ensure_valid_options_for_connection! + end + + def execute + message = "#{model} " + message += "Bulk " if inserts.many? + message += (on_duplicate == :update ? "Upsert" : "Insert") + connection.exec_query to_sql, message + end + + def updatable_columns + keys - readonly_columns - unique_by_columns + end + + def primary_keys + Array(model.primary_key) + end + + + def skip_duplicates? + on_duplicate == :skip + end + + def update_duplicates? + on_duplicate == :update + end + + def map_key_with_value + inserts.map do |attributes| + attributes = attributes.stringify_keys + verify_attributes(attributes) + + keys.map do |key| + yield key, attributes[key] + end + end + end + + private + def find_unique_index_for(unique_by) + match = Array(unique_by).map(&:to_s) + + if index = unique_indexes.find { |i| match.include?(i.name) || i.columns == match } + index + else + raise ArgumentError, "No unique index found for #{unique_by}" + end + end + + def unique_indexes + connection.schema_cache.indexes(model.table_name).select(&:unique) + end + + + def ensure_valid_options_for_connection! + if returning && !connection.supports_insert_returning? + raise ArgumentError, "#{connection.class} does not support :returning" + end + + if skip_duplicates? && !connection.supports_insert_on_duplicate_skip? + raise ArgumentError, "#{connection.class} does not support skipping duplicates" + end + + if update_duplicates? && !connection.supports_insert_on_duplicate_update? + raise ArgumentError, "#{connection.class} does not support upsert" + end + + if unique_by && !connection.supports_insert_conflict_target? + raise ArgumentError, "#{connection.class} does not support :unique_by" + end + end + + + def to_sql + connection.build_insert_sql(ActiveRecord::InsertAll::Builder.new(self)) + end + + + def readonly_columns + primary_keys + model.readonly_attributes.to_a + end + + def unique_by_columns + Array(unique_by&.columns) + end + + + def verify_attributes(attributes) + if keys != attributes.keys.to_set + raise ArgumentError, "All objects being inserted must have the same keys" + end + end + + + class Builder + attr_reader :model + + delegate :skip_duplicates?, :update_duplicates?, :keys, to: :insert_all + + def initialize(insert_all) + @insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection + end + + def into + "INTO #{model.quoted_table_name}(#{columns_list})" + end + + def values_list + types = extract_types_from_columns_on(model.table_name, keys: keys) + + values_list = insert_all.map_key_with_value do |key, value| + connection.with_yaml_fallback(types[key].serialize(value)) + end + + Arel::InsertManager.new.create_values_list(values_list).to_sql + end + + def returning + format_columns(insert_all.returning) if insert_all.returning + end + + def conflict_target + if index = insert_all.unique_by + sql = +"(#{format_columns(index.columns)})" + sql << " WHERE #{index.where}" if index.where + sql + elsif update_duplicates? + "(#{format_columns(insert_all.primary_keys)})" + end + end + + def updatable_columns + quote_columns(insert_all.updatable_columns) + end + + private + attr_reader :connection, :insert_all + + def columns_list + format_columns(insert_all.keys) + end + + def extract_types_from_columns_on(table_name, keys:) + columns = connection.schema_cache.columns_hash(table_name) + + unknown_column = (keys - columns.keys).first + raise UnknownAttributeError.new(model.new, unknown_column) if unknown_column + + keys.map { |key| [ key, connection.lookup_cast_type_from_column(columns[key]) ] }.to_h + end + + def format_columns(columns) + quote_columns(columns).join(",") + end + + def quote_columns(columns) + columns.map(&connection.method(:quote_column_name)) + end + end + end +end diff --git a/activerecord/lib/active_record/integration.rb b/activerecord/lib/active_record/integration.rb index 6cf26a9792..b769541e95 100644 --- a/activerecord/lib/active_record/integration.rb +++ b/activerecord/lib/active_record/integration.rb @@ -20,7 +20,7 @@ module ActiveRecord # 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. + # This is +true+, by default on Rails 5.2 and above. class_attribute :cache_versioning, instance_writer: false, default: false end @@ -60,24 +60,15 @@ module ActiveRecord # the cache key will also include a version. # # Product.cache_versioning = false - # Person.find(5).cache_key # => "people/5-20071224150000" (updated_at available) - def cache_key(*timestamp_names) + # Product.find(5).cache_key # => "products/5-20071224150000" (updated_at available) + def cache_key if new_record? "#{model_name.cache_key}/new" else - if cache_version && timestamp_names.none? + if cache_version "#{model_name.cache_key}/#{id}" else - 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 - - max_updated_column_timestamp(timestamp_names) - else - max_updated_column_timestamp - end + timestamp = max_updated_column_timestamp if timestamp timestamp = timestamp.utc.to_s(cache_timestamp_format) @@ -96,8 +87,19 @@ module ActiveRecord # 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) + return unless cache_versioning + + if has_attribute?("updated_at") + timestamp = updated_at_before_type_cast + if can_use_fast_cache_version?(timestamp) + raw_timestamp_to_cache_version(timestamp) + elsif timestamp = updated_at + timestamp.utc.to_s(cache_timestamp_format) + end + else + if self.class.has_attribute?("updated_at") + raise ActiveModel::MissingAttributeError, "missing attribute: updated_at" + end end end @@ -150,6 +152,48 @@ module ActiveRecord end end end + + def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: + collection.compute_cache_key(timestamp_column) + end end + + private + # Detects if the value before type cast + # can be used to generate a cache_version. + # + # The fast cache version only works with a + # string value directly from the database. + # + # We also must check if the timestamp format has been changed + # or if the timezone is not set to UTC then + # we cannot apply our transformations correctly. + def can_use_fast_cache_version?(timestamp) + timestamp.is_a?(String) && + cache_timestamp_format == :usec && + default_timezone == :utc && + !updated_at_came_from_user? + end + + # Converts a raw database string to `:usec` + # format. + # + # Example: + # + # timestamp = "2018-10-15 20:02:15.266505" + # raw_timestamp_to_cache_version(timestamp) + # # => "20181015200215266505" + # + # PostgreSQL truncates trailing zeros, + # https://github.com/postgres/postgres/commit/3e1beda2cde3495f41290e1ece5d544525810214 + # to account for this we pad the output with zeros + def raw_timestamp_to_cache_version(timestamp) + key = timestamp.delete("- :.") + if key.length < 20 + key.ljust(20, "0") + else + key + end + end end end diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index 3626a13d7c..e6166581f1 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -8,12 +8,16 @@ module ActiveRecord # as which environment migrations were run in. class InternalMetadata < ActiveRecord::Base # :nodoc: class << self + def _internal? + true + end + def primary_key "key" end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{internal_metadata_table_name}#{table_name_suffix}" end def []=(key, value) @@ -40,6 +44,10 @@ module ActiveRecord end end end + + def drop_table + connection.drop_table table_name, if_exists: true + end end end end diff --git a/activerecord/lib/active_record/locking/optimistic.rb b/activerecord/lib/active_record/locking/optimistic.rb index 7f096bb532..4a3a31fc95 100644 --- a/activerecord/lib/active_record/locking/optimistic.rb +++ b/activerecord/lib/active_record/locking/optimistic.rb @@ -61,7 +61,7 @@ module ActiveRecord end private - 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 @@ -165,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 5d1d15c94d..130ef8a330 100644 --- a/activerecord/lib/active_record/locking/pessimistic.rb +++ b/activerecord/lib/active_record/locking/pessimistic.rb @@ -14,9 +14,9 @@ module ActiveRecord # of your own such as 'LOCK IN SHARE MODE' or 'FOR UPDATE NOWAIT'. Example: # # Account.transaction do - # # select * from accounts where name = 'shugo' limit 1 for update - # shugo = Account.where("name = 'shugo'").lock(true).first - # yuko = Account.where("name = 'yuko'").lock(true).first + # # select * from accounts where name = 'shugo' limit 1 for update nowait + # shugo = Account.lock("FOR UPDATE NOWAIT").find_by(name: "shugo") + # yuko = Account.lock("FOR UPDATE NOWAIT").find_by(name: "yuko") # shugo.balance -= 100 # shugo.save! # yuko.balance += 100 diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index 9234029c22..6b84431343 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -4,6 +4,8 @@ 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 @@ -100,36 +102,15 @@ module ActiveRecord end def log_query_source - source_line, line_number = extract_callstack(caller_locations) - - if source_line - if defined?(::Rails.root) - app_root = "#{::Rails.root.to_s}/".freeze - source_line = source_line.sub(app_root, "") - end - - logger.debug(" ↳ #{ source_line }:#{ line_number }") - end - end + source = extract_query_source_location(caller) - def extract_callstack(callstack) - line = callstack.find do |frame| - frame.absolute_path && !ignored_callstack(frame.absolute_path) + if source + logger.debug(" ↳ #{source}") end - - offending_line = line || callstack.first - - [ - offending_line.path, - offending_line.lineno - ] end - RAILS_GEM_ROOT = File.expand_path("../../../..", __FILE__) + "/" - - def ignored_callstack(path) - path.start_with?(RAILS_GEM_ROOT) || - path.start_with?(RbConfig::CONFIG["rubylibdir"]) + def extract_query_source_location(locations) + backtrace_cleaner.clean(locations).first end end end diff --git a/activerecord/lib/active_record/middleware/database_selector.rb b/activerecord/lib/active_record/middleware/database_selector.rb new file mode 100644 index 0000000000..b5b5df074c --- /dev/null +++ b/activerecord/lib/active_record/middleware/database_selector.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "active_record/middleware/database_selector/resolver" + +module ActiveRecord + module Middleware + # The DatabaseSelector Middleware provides a framework for automatically + # swapping from the primary to the replica database connection. Rails + # provides a basic framework to determine when to swap and allows for + # applications to write custom strategy classes to override the default + # behavior. + # + # The resolver class defines when the application should switch (i.e. read + # from the primary if a write occurred less than 2 seconds ago) and a + # resolver context class that sets a value that helps the resolver class + # decide when to switch. + # + # Rails default middleware uses the request's session to set a timestamp + # that informs the application when to read from a primary or read from a + # replica. + # + # To use the DatabaseSelector in your application with default settings add + # the following options to your environment config: + # + # config.active_record.database_selector = { delay: 2.seconds } + # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver + # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + # + # New applications will include these lines commented out in the production.rb. + # + # The default behavior can be changed by setting the config options to a + # custom class: + # + # config.active_record.database_selector = { delay: 2.seconds } + # config.active_record.database_resolver = MyResolver + # config.active_record.database_resolver_context = MyResolver::MySession + class DatabaseSelector + def initialize(app, resolver_klass = Resolver, context_klass = Resolver::Session, options = {}) + @app = app + @resolver_klass = resolver_klass + @context_klass = context_klass + @options = options + end + + attr_reader :resolver_klass, :context_klass, :options + + # Middleware that determines which database connection to use in a multiple + # database application. + def call(env) + request = ActionDispatch::Request.new(env) + + select_database(request) do + @app.call(env) + end + end + + private + + def select_database(request, &blk) + context = context_klass.call(request) + resolver = resolver_klass.call(context, options) + + if reading_request?(request) + resolver.read(&blk) + else + resolver.write(&blk) + end + end + + def reading_request?(request) + request.get? || request.head? + end + end + end +end diff --git a/activerecord/lib/active_record/middleware/database_selector/resolver.rb b/activerecord/lib/active_record/middleware/database_selector/resolver.rb new file mode 100644 index 0000000000..80b8cd7cae --- /dev/null +++ b/activerecord/lib/active_record/middleware/database_selector/resolver.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "active_record/middleware/database_selector/resolver/session" + +module ActiveRecord + module Middleware + class DatabaseSelector + # The Resolver class is used by the DatabaseSelector middleware to + # determine which database the request should use. + # + # To change the behavior of the Resolver class in your application, + # create a custom resolver class that inherits from + # DatabaseSelector::Resolver and implements the methods that need to + # be changed. + # + # By default the Resolver class will send read traffic to the replica + # if it's been 2 seconds since the last write. + class Resolver # :nodoc: + SEND_TO_REPLICA_DELAY = 2.seconds + + def self.call(context, options = {}) + new(context, options) + end + + def initialize(context, options = {}) + @context = context + @options = options + @delay = @options && @options[:delay] ? @options[:delay] : SEND_TO_REPLICA_DELAY + @instrumenter = ActiveSupport::Notifications.instrumenter + end + + attr_reader :context, :delay, :instrumenter + + def read(&blk) + if read_from_primary? + read_from_primary(&blk) + else + read_from_replica(&blk) + end + end + + def write(&blk) + write_to_primary(&blk) + end + + private + + def read_from_primary(&blk) + ActiveRecord::Base.connection.while_preventing_writes do + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do + instrumenter.instrument("database_selector.active_record.read_from_primary") do + yield + end + end + end + end + + def read_from_replica(&blk) + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do + instrumenter.instrument("database_selector.active_record.read_from_replica") do + yield + end + end + end + + def write_to_primary(&blk) + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do + instrumenter.instrument("database_selector.active_record.wrote_to_primary") do + yield + ensure + context.update_last_write_timestamp + end + end + end + + def read_from_primary? + !time_since_last_write_ok? + end + + def send_to_replica_delay + delay + end + + def time_since_last_write_ok? + Time.now - context.last_write_timestamp >= send_to_replica_delay + end + end + end + end +end diff --git a/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb b/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb new file mode 100644 index 0000000000..df7af054b7 --- /dev/null +++ b/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ActiveRecord + module Middleware + class DatabaseSelector + class Resolver + # The session class is used by the DatabaseSelector::Resolver to save + # timestamps of the last write in the session. + # + # The last_write is used to determine whether it's safe to read + # from the replica or the request needs to be sent to the primary. + class Session # :nodoc: + def self.call(request) + new(request.session) + end + + # Converts time to a timestamp that represents milliseconds since + # epoch. + def self.convert_time_to_timestamp(time) + time.to_i * 1000 + time.usec / 1000 + end + + # Converts milliseconds since epoch timestamp into a time object. + def self.convert_timestamp_to_time(timestamp) + timestamp ? Time.at(timestamp / 1000, (timestamp % 1000) * 1000) : Time.at(0) + end + + def initialize(session) + @session = session + end + + attr_reader :session + + def last_write_timestamp + self.class.convert_timestamp_to_time(session[:last_write]) + end + + def update_last_write_timestamp + session[:last_write] = self.class.convert_time_to_timestamp(Time.now) + end + end + end + end + end +end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 663b3c590a..ed0c6d48b8 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require "benchmark" require "set" require "zlib" require "active_support/core_ext/module/attribute_accessors" module ActiveRecord - class MigrationError < ActiveRecordError#:nodoc: + class MigrationError < ActiveRecordError #:nodoc: def initialize(message = nil) message = "\n\n#{message}\n\n" if message super @@ -22,7 +23,7 @@ module ActiveRecord # t.string :zipcode # end # - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # ADD CONSTRAINT zipchk # CHECK (char_length(zipcode) = 5) NO INHERIT; @@ -40,7 +41,7 @@ module ActiveRecord # t.string :zipcode # end # - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # ADD CONSTRAINT zipchk # CHECK (char_length(zipcode) = 5) NO INHERIT; @@ -48,7 +49,7 @@ module ActiveRecord # end # # def down - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # DROP CONSTRAINT zipchk # SQL @@ -67,7 +68,7 @@ module ActiveRecord # # reversible do |dir| # dir.up do - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # ADD CONSTRAINT zipchk # CHECK (char_length(zipcode) = 5) NO INHERIT; @@ -75,7 +76,7 @@ module ActiveRecord # end # # dir.down do - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # DROP CONSTRAINT zipchk # SQL @@ -86,7 +87,7 @@ module ActiveRecord class IrreversibleMigration < MigrationError end - class DuplicateMigrationVersionError < MigrationError#:nodoc: + class DuplicateMigrationVersionError < MigrationError #:nodoc: def initialize(version = nil) if version super("Multiple migrations have the version number #{version}.") @@ -96,7 +97,7 @@ module ActiveRecord end end - class DuplicateMigrationNameError < MigrationError#:nodoc: + class DuplicateMigrationNameError < MigrationError #:nodoc: def initialize(name = nil) if name super("Multiple migrations have the name #{name}.") @@ -116,7 +117,7 @@ module ActiveRecord end end - class IllegalMigrationNameError < MigrationError#:nodoc: + class IllegalMigrationNameError < MigrationError #:nodoc: def initialize(name = nil) if name super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed).") @@ -126,12 +127,12 @@ module ActiveRecord end end - class PendingMigrationError < MigrationError#:nodoc: + 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 @@ -139,8 +140,8 @@ module ActiveRecord end 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 + DEFAULT_MESSAGE = "Cannot run migrations because another migration process is currently running." + RELEASE_LOCK_FAILED_MESSAGE = "Failed to release advisory lock" def initialize(message = DEFAULT_MESSAGE) super @@ -149,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 @@ -160,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".dup + msg = +"You are attempting to run a destructive action against your '#{env}' database.\n" 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) @@ -169,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".dup + msg = +"You are attempting to modify a database that was last run in `#{ stored }` environment.\n" 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 @@ -307,7 +308,7 @@ module ActiveRecord # named +column_name+ from the table called +table_name+. # * <tt>remove_columns(table_name, *column_names)</tt>: Removes the given # columns from the table definition. - # * <tt>remove_foreign_key(from_table, options_or_to_table)</tt>: Removes the + # * <tt>remove_foreign_key(from_table, to_table = nil, **options)</tt>: Removes the # given foreign key from the table called +table_name+. # * <tt>remove_index(table_name, column: column_names)</tt>: Removes the index # specified by +column_names+. @@ -351,7 +352,7 @@ 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 @@ -519,10 +520,10 @@ module ActiveRecord autoload :Compatibility, "active_record/migration/compatibility" # This must be defined before the inherited hook, below - class Current < Migration # :nodoc: + class Current < Migration #:nodoc: end - def self.inherited(subclass) # :nodoc: + def self.inherited(subclass) #:nodoc: super if subclass.superclass == Migration raise StandardError, "Directly inheriting from ActiveRecord::Migration is not supported. " \ @@ -540,7 +541,7 @@ module ActiveRecord ActiveRecord::VERSION::STRING.to_f end - MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ # :nodoc: + MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ #:nodoc: # This class is used to verify that all migrations have been run before # loading a web page if <tt>config.active_record.migration_error</tt> is set to :page_load @@ -567,10 +568,10 @@ module ActiveRecord end class << self - attr_accessor :delegate # :nodoc: - attr_accessor :disable_ddl_transaction # :nodoc: + attr_accessor :delegate #:nodoc: + attr_accessor :disable_ddl_transaction #:nodoc: - def nearest_delegate # :nodoc: + def nearest_delegate #:nodoc: delegate || superclass.nearest_delegate end @@ -594,13 +595,13 @@ module ActiveRecord end end - def maintain_test_schema! # :nodoc: + def maintain_test_schema! #:nodoc: if ActiveRecord::Base.maintain_test_schema suppress_messages { load_schema_if_pending! } end end - def method_missing(name, *args, &block) # :nodoc: + def method_missing(name, *args, &block) #:nodoc: nearest_delegate.send(name, *args, &block) end @@ -617,7 +618,7 @@ module ActiveRecord end end - def disable_ddl_transaction # :nodoc: + def disable_ddl_transaction #:nodoc: self.class.disable_ddl_transaction end @@ -677,15 +678,13 @@ module ActiveRecord if connection.respond_to? :revert connection.revert { yield } else - recorder = CommandRecorder.new(connection) + recorder = command_recorder @connection = recorder suppress_messages do connection.revert { yield } end @connection = recorder.delegate - recorder.commands.each do |cmd, args, block| - send(cmd, *args, &block) - end + recorder.replay(self) end end end @@ -694,7 +693,7 @@ module ActiveRecord connection.respond_to?(:reverting) && connection.reverting end - ReversibleBlockHelper = Struct.new(:reverting) do # :nodoc: + ReversibleBlockHelper = Struct.new(:reverting) do #:nodoc: def up yield unless reverting end @@ -830,10 +829,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 @@ -843,6 +846,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 @@ -885,7 +889,7 @@ module ActiveRecord source_migrations.each do |migration| source = File.binread(migration.filename) inserted_comment = "# This migration comes from #{scope} (originally #{migration.version})\n" - magic_comments = "".dup + magic_comments = +"" 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) @@ -956,6 +960,10 @@ module ActiveRecord yield end end + + def command_recorder + CommandRecorder.new(connection) + end end # MigrationProxy is used to defer loading of the actual migration classes @@ -998,7 +1006,7 @@ module ActiveRecord end end - class MigrationContext # :nodoc: + class MigrationContext #:nodoc: attr_reader :migrations_paths def initialize(migrations_paths) @@ -1079,10 +1087,6 @@ module ActiveRecord migrations.last || NullMigration.new 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) @@ -1114,11 +1118,6 @@ module ActiveRecord (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 @@ -1137,6 +1136,15 @@ module ActiveRecord end private + def migration_files + paths = Array(migrations_paths) + Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] + end + + def parse_migration_filename(filename) + File.basename(filename).scan(Migration::MigrationFilenameRegexp).first + end + def move(direction, steps) migrator = Migrator.new(direction, migrations) @@ -1157,17 +1165,10 @@ module ActiveRecord end end - class Migrator # :nodoc: + class Migrator #:nodoc: class << self attr_accessor :migrations_paths - 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 - # For cases where a table doesn't exist like loading from schema cache def current_version MigrationContext.new(migrations_paths).current_version @@ -1293,7 +1294,7 @@ module ActiveRecord record_version_state_after_migrating(migration.version) end rescue => e - msg = "An error has occurred, ".dup + msg = +"An error has occurred, " msg << "this and " if use_transaction?(migration) msg << "all later migrations canceled:\n\n#{e}" raise StandardError, msg, e.backtrace @@ -1322,7 +1323,7 @@ module ActiveRecord def record_version_state_after_migrating(version) if down? migrated.delete(version) - ActiveRecord::SchemaMigration.where(version: version.to_s).delete_all + ActiveRecord::SchemaMigration.delete_by(version: version.to_s) else migrated << version ActiveRecord::SchemaMigration.create!(version: version.to_s) @@ -1351,7 +1352,7 @@ module ActiveRecord end def use_advisory_lock? - Base.connection.supports_advisory_locks? + Base.connection.advisory_locks_enabled? end def with_advisory_lock diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index 087632b10f..8e7f596076 100644 --- a/activerecord/lib/active_record/migration/command_recorder.rb +++ b/activerecord/lib/active_record/migration/command_recorder.rb @@ -108,11 +108,17 @@ module ActiveRecord yield delegate.update_table_definition(table_name, self) end + def replay(migration) + commands.each do |cmd, args, block| + migration.send(cmd, *args, &block) + end + end + private module StraightReversions # :nodoc: private - { transaction: :transaction, + { execute_block: :execute_block, create_table: :drop_table, create_join_table: :drop_join_table, @@ -133,6 +139,17 @@ module ActiveRecord include StraightReversions + def invert_transaction(args) + sub_recorder = CommandRecorder.new(delegate) + sub_recorder.revert { yield } + + invertions_proc = proc { + sub_recorder.replay(self) + } + + [:transaction, args, invertions_proc] + end + def invert_drop_table(args, &block) if args.size == 1 && block == nil raise ActiveRecord::IrreversibleMigration, "To avoid mistakes, drop_table is only reversible if given options or a block (can be empty)." @@ -214,11 +231,15 @@ 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) + options = args.extract_options! + from_table, to_table = args + + to_table ||= options.delete(:to_table) + + raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? reversed_args = [from_table, to_table] - reversed_args << remove_options if remove_options + reversed_args << options unless options.empty? [:add_foreign_key, reversed_args] end diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 0edaaa0cf9..abc939826b 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -16,24 +16,79 @@ module ActiveRecord V6_0 = Current class V5_2 < V6_0 + module TableDefinition + def timestamps(**options) + options[:precision] ||= nil + super + end + end + + module CommandRecorder + def invert_transaction(args, &block) + [:transaction, args, block] + end + end + + def create_table(table_name, **options) + if block_given? + super { |t| yield compatible_table_definition(t) } + else + super + end + end + + def change_table(table_name, **options) + if block_given? + super { |t| yield compatible_table_definition(t) } + else + super + end + end + + def create_join_table(table_1, table_2, **options) + if block_given? + super { |t| yield compatible_table_definition(t) } + else + super + end + end + + def add_timestamps(table_name, **options) + options[:precision] ||= nil + super + end + + private + def compatible_table_definition(t) + class << t + prepend TableDefinition + end + t + end + + def command_recorder + recorder = super + class << recorder + prepend CommandRecorder + end + recorder + end 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) + if connection.adapter_name == "PostgreSQL" + super(table_name, column_name, type, options.except(:default, :null, :comment)) + connection.change_column_default(table_name, column_name, options[:default]) if options.key?(:default) + connection.change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + connection.change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) else super end end def create_table(table_name, options = {}) - if adapter_name == "Mysql2" + if connection.adapter_name == "Mysql2" super(table_name, options: "ENGINE=InnoDB", **options) else super @@ -55,13 +110,13 @@ module ActiveRecord end def create_table(table_name, options = {}) - if adapter_name == "PostgreSQL" + if connection.adapter_name == "PostgreSQL" if options[:id] == :uuid && !options.key?(:default) options[:default] = "uuid_generate_v4()" end end - unless adapter_name == "Mysql2" && options[:id] == :bigint + unless connection.adapter_name == "Mysql2" && options[:id] == :bigint if [:integer, :bigint].include?(options[:id]) && !options.key?(:default) options[:default] = nil end @@ -74,35 +129,12 @@ module ActiveRecord options[:id] = :integer end - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end - end - - def change_table(table_name, options = {}) - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end + super 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 + super end def add_column(table_name, column_name, type, options = {}) @@ -123,7 +155,7 @@ module ActiveRecord class << t prepend TableDefinition end - t + super end end @@ -141,33 +173,13 @@ module ActiveRecord end end - def create_table(table_name, options = {}) - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end - end - - def change_table(table_name, options = {}) - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end - end - - def add_reference(*, **options) + def add_reference(table_name, ref_name, **options) options[:index] ||= false super end alias :add_belongs_to :add_reference - def add_timestamps(_, **options) + def add_timestamps(table_name, **options) options[:null] = true if options[:null].nil? super end @@ -178,7 +190,7 @@ module ActiveRecord if options[:name].present? options[:name].to_s else - index_name(table_name, column: column_names) + connection.index_name(table_name, column: column_names) end super end @@ -198,15 +210,17 @@ module ActiveRecord end def index_name_for_remove(table_name, options = {}) - index_name = index_name(table_name, options) + index_name = connection.index_name(table_name, options) - unless index_name_exists?(table_name, index_name) + unless connection.index_name_exists?(table_name, index_name) if options.is_a?(Hash) && options.has_key?(:name) options_without_column = options.dup options_without_column.delete :column - index_name_without_column = index_name(table_name, options_without_column) + index_name_without_column = connection.index_name(table_name, options_without_column) - return index_name_without_column if index_name_exists?(table_name, index_name_without_column) + if connection.index_name_exists?(table_name, index_name_without_column) + return index_name_without_column + end end raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' does not exist" diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 694ff85fa1..55fc58e339 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -102,6 +102,21 @@ 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: implicit_order_column + # :call-seq: implicit_order_column + # + # The name of the column records are ordered by if no explicit order clause + # is used during an ordered finder call. If not set the primary key is used. + + ## + # :singleton-method: implicit_order_column= + # :call-seq: implicit_order_column=(column_name) + # + # Sets the column to sort records by when no explicit order clause is used + # during an ordered finder call. Useful when the primary key is not an + # auto-incrementing integer, for example when it's a UUID. Note that using + # a non-unique column can result in non-deterministic results. included do mattr_accessor :primary_key_prefix_type, instance_writer: false @@ -110,6 +125,7 @@ module ActiveRecord 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 :implicit_order_column, instance_accessor: false self.protected_environments = ["production"] self.inheritance_column = "type" @@ -218,11 +234,11 @@ module ActiveRecord end def full_table_name_prefix #:nodoc: - (parents.detect { |p| p.respond_to?(:table_name_prefix) } || self).table_name_prefix + (module_parents.detect { |p| p.respond_to?(:table_name_prefix) } || self).table_name_prefix end def full_table_name_suffix #:nodoc: - (parents.detect { |p| p.respond_to?(:table_name_suffix) } || self).table_name_suffix + (module_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, @@ -375,7 +391,7 @@ module ActiveRecord # default values when instantiating the Active Record object for this table. def column_defaults load_schema - @column_defaults ||= _default_attributes.to_hash + @column_defaults ||= _default_attributes.deep_dup.to_hash end def _default_attributes # :nodoc: @@ -388,6 +404,11 @@ module ActiveRecord @column_names ||= columns.map(&:name) end + def symbol_column_to_string(name_symbol) # :nodoc: + @symbol_column_to_string_name_hash ||= column_names.index_by(&:to_sym) + @symbol_column_to_string_name_hash[name_symbol] + end + # Returns an array of column objects where the primary id, all columns ending in "_id" or "_count", # and columns used for single table inheritance have been removed. def content_columns @@ -477,6 +498,7 @@ module ActiveRecord def reload_schema_from_cache @arel_table = nil @column_names = nil + @symbol_column_to_string_name_hash = nil @attribute_types = nil @content_columns = nil @default_attributes = nil @@ -503,9 +525,9 @@ module ActiveRecord def compute_table_name if base_class? # Nested classes are prefixed with singular parent table name. - if parent < Base && !parent.abstract_class? - contained = parent.table_name - contained = contained.singularize if parent.pluralize_table_names + if module_parent < Base && !module_parent.abstract_class? + contained = module_parent.table_name + contained = contained.singularize if module_parent.pluralize_table_names contained += "_" end diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb index fa20bce3a9..8b9098df6c 100644 --- a/activerecord/lib/active_record/nested_attributes.rb +++ b/activerecord/lib/active_record/nested_attributes.rb @@ -426,7 +426,7 @@ module ActiveRecord existing_record.assign_attributes(assignable_attributes) association(association_name).initialize_attributes(existing_record) else - method = "build_#{association_name}" + method = :"build_#{association_name}" if respond_to?(method) send(method, assignable_attributes) else @@ -501,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 754c891884..697076bdae 100644 --- a/activerecord/lib/active_record/no_touching.rb +++ b/activerecord/lib/active_record/no_touching.rb @@ -43,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/persistence.rb b/activerecord/lib/active_record/persistence.rb index 7721e6b691..e05490753f 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_record/insert_all" + module ActiveRecord # = Active Record \Persistence module Persistence @@ -55,6 +57,192 @@ module ActiveRecord end end + # Inserts a single record into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all</tt> for documentation. + def insert(attributes, returning: nil, unique_by: nil) + insert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Inserts multiple records into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Rows are considered to be unique by every unique index on the table. Any + # duplicate rows are skipped. + # Override with <tt>:unique_by</tt> (see below). + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # [:unique_by] + # (PostgreSQL and SQLite only) By default rows are considered to be unique + # by every unique index on the table. Any duplicate rows are skipped. + # + # To skip rows according to just one unique index pass <tt>:unique_by</tt>. + # + # Consider a Book model where no duplicate ISBNs make sense, but if any + # row has an existing id, or is not unique by another unique index, + # <tt>ActiveRecord::RecordNotUnique</tt> is raised. + # + # Unique indexes can be identified by columns or name: + # + # unique_by: :isbn + # unique_by: %i[ author_id name ] + # unique_by: :index_books_on_isbn + # + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. + # + # ==== Example + # + # # Insert records and skip inserting any duplicates. + # # Here "Eloquent Ruby" is skipped because its id is not unique. + # + # Book.insert_all([ + # { id: 1, title: "Rework", author: "David" }, + # { id: 1, title: "Eloquent Ruby", author: "Russ" } + # ]) + def insert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by).execute + end + + # Inserts a single record into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all!</tt> for more. + def insert!(attributes, returning: nil) + insert_all!([ attributes ], returning: returning) + end + + # Inserts multiple records into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Raises <tt>ActiveRecord::RecordNotUnique</tt> if any rows violate a + # unique index on the table. In that case, no rows are inserted. + # + # To skip duplicate rows, see <tt>ActiveRecord::Persistence#insert_all</tt>. + # To replace them, see <tt>ActiveRecord::Persistence#upsert_all</tt>. + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # ==== Examples + # + # # Insert multiple records + # Book.insert_all!([ + # { title: "Rework", author: "David" }, + # { title: "Eloquent Ruby", author: "Russ" } + # ]) + # + # # Raises ActiveRecord::RecordNotUnique because "Eloquent Ruby" + # # does not have a unique id. + # Book.insert_all!([ + # { id: 1, title: "Rework", author: "David" }, + # { id: 1, title: "Eloquent Ruby", author: "Russ" } + # ]) + def insert_all!(attributes, returning: nil) + InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute + end + + # Updates or inserts (upserts) a single record into the database in a + # single SQL INSERT statement. It does not instantiate any models nor does + # it trigger Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#upsert_all</tt> for documentation. + def upsert(attributes, returning: nil, unique_by: nil) + upsert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Updates or inserts (upserts) multiple records into the database in a + # single SQL INSERT statement. It does not instantiate any models nor does + # it trigger Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # [:unique_by] + # (PostgreSQL and SQLite only) By default rows are considered to be unique + # by every unique index on the table. Any duplicate rows are skipped. + # + # To skip rows according to just one unique index pass <tt>:unique_by</tt>. + # + # Consider a Book model where no duplicate ISBNs make sense, but if any + # row has an existing id, or is not unique by another unique index, + # <tt>ActiveRecord::RecordNotUnique</tt> is raised. + # + # Unique indexes can be identified by columns or name: + # + # unique_by: :isbn + # unique_by: %i[ author_id name ] + # unique_by: :index_books_on_isbn + # + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. + # + # ==== Examples + # + # # Inserts multiple records, performing an upsert when records have duplicate ISBNs. + # # Here "Eloquent Ruby" overwrites "Rework" because its ISBN is duplicate. + # + # Book.upsert_all([ + # { title: "Rework", author: "David", isbn: "1" }, + # { title: "Eloquent Ruby", author: "Russ", isbn: "1" } + # ], unique_by: :isbn) + # + # Book.find_by(isbn: "1").title # => "Eloquent Ruby" + def upsert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :update, returning: returning, unique_by: unique_by).execute + end + # Given an attributes hash, +instantiate+ returns a new instance of # the appropriate class. Accepts only keys as strings. # @@ -67,8 +255,7 @@ 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. @@ -143,7 +330,7 @@ module ActiveRecord end end - # Deletes the row with a primary key matching the +id+ argument, using a + # Deletes the row with a primary key matching the +id+ argument, using an # SQL +DELETE+ statement, and returns the number of rows deleted. Active # Record objects are not instantiated, so the object's callbacks are not # executed, including any <tt>:dependent</tt> association options. @@ -162,7 +349,7 @@ module ActiveRecord # # Delete multiple rows # Todo.delete([2,3,4]) def delete(id_or_array) - where(primary_key => id_or_array).delete_all + delete_by(primary_key => id_or_array) end def _insert_record(values) # :nodoc: @@ -178,7 +365,7 @@ module ActiveRecord end if values.empty? - im = arel_table.compile_insert(connection.empty_insert_statement_value) + 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)) @@ -208,6 +395,13 @@ module ActiveRecord 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_with_attributes(attributes, &block) + end + # Called by +instantiate+ to decide which class to use for a new # record instance. # @@ -373,8 +567,7 @@ module ActiveRecord became = klass.allocate became.send(:initialize) became.instance_variable_set("@attributes", @attributes) - became.instance_variable_set("@mutations_from_database", @mutations_from_database) if defined?(@mutations_from_database) - became.instance_variable_set("@changed_attributes", attributes_changed_by_setter) + became.instance_variable_set("@mutations_from_database", @mutations_from_database ||= nil) became.instance_variable_set("@new_record", new_record?) became.instance_variable_set("@destroyed", destroyed?) became.errors.copy!(errors) @@ -430,7 +623,7 @@ module ActiveRecord end alias update_attributes update - deprecate :update_attributes + deprecate update_attributes: "please, use update instead" # 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. @@ -444,7 +637,7 @@ module ActiveRecord end alias update_attributes! update! - deprecate :update_attributes! + deprecate update_attributes!: "please, use update! instead" # Equivalent to <code>update_columns(name => value)</code>. def update_column(name, value) @@ -475,15 +668,16 @@ module ActiveRecord verify_readonly_attribute(key.to_s) end + id_in_database = self.id_in_database + attributes.each do |k, v| + write_attribute_without_type_cast(k, v) + end + affected_rows = self.class._update_record( attributes, self.class.primary_key => id_in_database ) - attributes.each do |k, v| - write_attribute_without_type_cast(k, v) - end - affected_rows == 1 end @@ -700,17 +894,16 @@ module ActiveRecord ) end - def create_or_update(*args, &block) + def create_or_update(**, &block) _raise_readonly_record_error if readonly? return false if destroyed? - result = new_record? ? _create_record(&block) : _update_record(*args, &block) + result = new_record? ? _create_record(&block) : _update_record(&block) result != false end # 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) - attribute_names &= self.class.column_names attribute_names = attributes_for_update(attribute_names) if attribute_names.empty? @@ -729,10 +922,12 @@ module ActiveRecord # Creates a record with values matching those of the instance attributes # and returns its id. def _create_record(attribute_names = self.attribute_names) - attribute_names &= self.class.column_names - attributes_values = 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._insert_record(attributes_values) self.id ||= new_id if self.class.primary_key @new_record = false @@ -753,6 +948,8 @@ module ActiveRecord @_association_destroy_exception = nil end + # The name of the method used to touch a +belongs_to+ association when the + # +:touch+ option is used. def belongs_to_touch_method :touch end diff --git a/activerecord/lib/active_record/query_cache.rb b/activerecord/lib/active_record/query_cache.rb index 28194c7c46..43a21e629e 100644 --- a/activerecord/lib/active_record/query_cache.rb +++ b/activerecord/lib/active_record/query_cache.rb @@ -26,15 +26,22 @@ module ActiveRecord end def self.run - ActiveRecord::Base.connection_handler.connection_pool_list. - reject { |p| p.query_cache_enabled }.each { |p| p.enable_query_cache! } + pools = [] + + ActiveRecord::Base.connection_handlers.each do |key, handler| + pools << handler.connection_pool_list.reject { |p| p.query_cache_enabled }.each { |p| p.enable_query_cache! } + end + + pools.flatten end 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? + ActiveRecord::Base.connection_handlers.each do |_, handler| + handler.connection_pool_list.each do |pool| + pool.release_connection if pool.active_connection? && !pool.connection.transaction_open? + end end end diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index d33d36ac02..08cfc3fe5f 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -2,31 +2,36 @@ module ActiveRecord module Querying - delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, to: :all - delegate :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, to: :all - delegate :first_or_create, :first_or_create!, :first_or_initialize, to: :all - delegate :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, to: :all - delegate :find_by, :find_by!, to: :all - delegate :destroy_all, :delete_all, :update_all, to: :all - delegate :find_each, :find_in_batches, :in_batches, to: :all - delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, - :having, :create_with, :distinct, :references, :none, :unscope, :merge, to: :all - delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all - delegate :pluck, :pick, :ids, to: :all + QUERYING_METHODS = [ + :find, :find_by, :find_by!, :take, :take!, :first, :first!, :last, :last!, + :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, + :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, + :exists?, :any?, :many?, :none?, :one?, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, + :create_or_find_by, :create_or_find_by!, + :destroy_all, :delete_all, :update_all, :touch_all, :destroy_by, :delete_by, + :find_each, :find_in_batches, :in_batches, + :select, :reselect, :order, :reorder, :group, :limit, :offset, :joins, :left_joins, :left_outer_joins, + :where, :rewhere, :preload, :extract_associated, :eager_load, :includes, :from, :lock, :readonly, :extending, :or, + :having, :create_with, :distinct, :references, :none, :unscope, :optimizer_hints, :merge, :except, :only, + :count, :average, :minimum, :maximum, :sum, :calculate, :annotate, + :pluck, :pick, :ids + ].freeze # :nodoc: + delegate(*QUERYING_METHODS, to: :all) # Executes a custom SQL query against your database and returns all the results. The results will - # be returned as an array with columns requested encapsulated as attributes of the model you call - # this method from. If you call <tt>Product.find_by_sql</tt> then the results will be returned in + # be returned as an array, with the requested columns encapsulated as attributes of the model you call + # this method from. For example, if you call <tt>Product.find_by_sql</tt>, then the results will be returned in # a +Product+ object with the attributes you specified in the SQL query. # - # If you call a complicated SQL query which spans multiple tables the columns specified by the + # If you call a complicated SQL query which spans multiple tables, the columns specified by the # SELECT will be attributes of the model, whether or not they are columns of the corresponding # table. # - # The +sql+ parameter is a full SQL query as a string. It will be called as is, there will be - # no database agnostic conversions performed. This should be a last resort because using, for example, - # MySQL specific terms will lock you to using that particular database engine or require you to + # The +sql+ parameter is a full SQL query as a string. It will be called as is; there will be + # no database agnostic conversions performed. This should be a last resort because using + # database-specific terms will lock you into using that particular database engine, or require you to # change your call if you switch engines. # # # A simple SQL query spanning multiple tables @@ -40,7 +45,7 @@ module ActiveRecord def find_by_sql(sql, binds = [], preparable: nil, &block) result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable) column_types = result_set.column_types.dup - columns_hash.each_key { |k| column_types.delete k } + attribute_types.each_key { |k| column_types.delete k } message_bus = ActiveSupport::Notifications.instrumenter payload = { @@ -49,13 +54,20 @@ 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 # Returns the result of an SQL statement that should only include a COUNT(*) in the SELECT part. # The use of this method should be restricted to complicated SQL queries that can't be executed - # using the ActiveRecord::Calculations class methods. Look into those before using this. + # using the ActiveRecord::Calculations class methods. Look into those before using this method, + # as it could lock you into a specific database engine or require a code change to switch + # database engines. # # Product.count_by_sql "SELECT COUNT(*) FROM sales s, customers c WHERE s.customer_id = c.id" # # => 12 diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 6ab80a654d..a1d7c893bf 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -77,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, @@ -84,6 +88,39 @@ module ActiveRecord end end + initializer "active_record.database_selector" do + if options = config.active_record.delete(:database_selector) + resolver = config.active_record.delete(:database_resolver) + operations = config.active_record.delete(:database_resolver_context) + config.app_middleware.use ActiveRecord::Middleware::DatabaseSelector, resolver, operations, options + end + end + + initializer "Check for cache versioning support" do + config.after_initialize do |app| + ActiveSupport.on_load(:active_record) do + if app.config.active_record.cache_versioning && Rails.cache + unless Rails.cache.class.try(:supports_cache_versioning?) + raise <<-end_error + +You're using a cache store that doesn't support native cache versioning. +Your best option is to upgrade to a newer version of #{Rails.cache.class} +that supports cache versioning (#{Rails.cache.class}.supports_cache_versioning? #=> true). + +Next best, switch to a different cache store that does support cache versioning: +https://guides.rubyonrails.org/caching_with_rails.html#cache-stores. + +To keep using the current cache store, you can turn off cache versioning entirely: + + config.active_record.cache_versioning = false + +end_error + end + end + end + end + end + initializer "active_record.check_schema_cache_dump" do if config.active_record.delete(:use_schema_cache_dump) config.after_initialize do |app| @@ -108,6 +145,26 @@ module ActiveRecord end end + initializer "active_record.define_attribute_methods" do |app| + config.after_initialize do + ActiveSupport.on_load(:active_record) do + if app.config.eager_load + descendants.each do |model| + # SchemaMigration and InternalMetadata both override `table_exists?` + # to bypass the schema cache, so skip them to avoid the extra queries. + next if model._internal? + + # If there's no connection yet, or the schema cache doesn't have the columns + # hash for the model cached, `define_attribute_methods` would trigger a query. + next unless model.connected? && model.connection.schema_cache.columns_hash?(model.table_name) + + model.define_attribute_methods + end + end + end + end + end + initializer "active_record.warn_on_records_fetched_greater_than" do if config.active_record.warn_on_records_fetched_greater_than ActiveSupport.on_load(:active_record) do @@ -118,8 +175,18 @@ module ActiveRecord initializer "active_record.set_configs" do |app| ActiveSupport.on_load(:active_record) do - configs = app.config.active_record.dup + configs = app.config.active_record + + represent_boolean_as_integer = configs.sqlite3.delete(:represent_boolean_as_integer) + + unless represent_boolean_as_integer.nil? + ActiveSupport.on_load(:active_record_sqlite3adapter) do + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = represent_boolean_as_integer + end + end + configs.delete(:sqlite3) + configs.each do |k, v| send "#{k}=", v end @@ -130,22 +197,9 @@ module ActiveRecord # and then establishes the connection. initializer "active_record.initialize_database" do ActiveSupport.on_load(:active_record) do + self.connection_handlers = { writing_role => ActiveRecord::Base.default_connection_handler } self.configurations = Rails.application.config.database_configuration - - begin - establish_connection - rescue ActiveRecord::NoDatabaseError - warn <<-end_warning -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. -end_warning - raise - end + establish_connection end end @@ -176,9 +230,7 @@ end_warning end initializer "active_record.set_executor_hooks" do - ActiveSupport.on_load(:active_record) do - ActiveRecord::QueryCache.install_executor_hooks - end + ActiveRecord::QueryCache.install_executor_hooks end initializer "active_record.add_watchable_files" do |app| @@ -203,32 +255,9 @@ end_warning 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 + initializer "active_record.set_filter_attributes" do + ActiveSupport.on_load(:active_record) do + self.filter_attributes += Rails.application.config.filter_parameters 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 index b5129e4239..d57680aaaa 100644 --- a/activerecord/lib/active_record/railties/collection_cache_association_loading.rb +++ b/activerecord/lib/active_record/railties/collection_cache_association_loading.rb @@ -3,7 +3,7 @@ module ActiveRecord module Railties # :nodoc: module CollectionCacheAssociationLoading #:nodoc: - def setup(context, options, block) + def setup(context, options, as, block) @relation = relation_from_options(options) super @@ -20,12 +20,12 @@ module ActiveRecord end end - def collection_without_template + def collection_without_template(*) @relation.preload_associations(@collection) if @relation super end - def collection_with_template + def collection_with_template(*) @relation.preload_associations(@collection) if @relation super end diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 24449e8df3..447def8d77 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -26,7 +26,7 @@ db_namespace = namespace :db do 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::DatabaseConfigurations.config_for_env_and_spec(Rails.env, spec_name) + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) ActiveRecord::Tasks::DatabaseTasks.create(db_config.config) end end @@ -45,7 +45,7 @@ db_namespace = namespace :db do 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::DatabaseConfigurations.config_for_env_and_spec(Rails.env, spec_name) + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) ActiveRecord::Tasks::DatabaseTasks.drop(db_config.config) end end @@ -66,6 +66,11 @@ db_namespace = namespace :db do end end + # desc "Truncates tables of each database for current environment" + task truncate_all: [:load_config, :check_protected_environments] do + ActiveRecord::Tasks::DatabaseTasks.truncate_all + end + # desc "Empty the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:purge:all to purge all databases in the config). Without RAILS_ENV it defaults to purging the development and test databases." task purge: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.purge_current @@ -73,8 +78,8 @@ db_namespace = namespace :db do desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)." task migrate: :load_config do - ActiveRecord::DatabaseConfigurations.configs_for(Rails.env) do |spec_name, config| - ActiveRecord::Base.establish_connection(config) + 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 @@ -99,7 +104,7 @@ db_namespace = namespace :db 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::DatabaseConfigurations.config_for_env_and_spec(Rails.env, spec_name) + 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 @@ -149,18 +154,21 @@ db_namespace = namespace :db do desc "Display status of migrations" task status: :load_config do - unless ActiveRecord::SchemaMigration.table_exists? - abort "Schema migrations table does not exist yet." + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.migrate_status end + end - # output - puts "\ndatabase: #{ActiveRecord::Base.connection_config[:database]}\n\n" - puts "#{'Status'.center(8)} #{'Migration ID'.ljust(14)} Migration Name" - puts "-" * 50 - ActiveRecord::Base.connection.migration_context.migrations_status.each do |status, version, name| - puts "#{status.center(8)} #{version.ljust(14)} #{name}" + namespace :status do + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + desc "Display status of migrations for #{spec_name} database" + 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_status + end end - puts end end @@ -188,11 +196,9 @@ db_namespace = namespace :db do # desc "Retrieves the collation for the current environment's database" task collation: :load_config do - begin - puts ActiveRecord::Tasks::DatabaseTasks.collation_current - rescue NoMethodError - $stderr.puts "Sorry, your database adapter is not supported yet. Feel free to submit a patch." - end + puts ActiveRecord::Tasks::DatabaseTasks.collation_current + rescue NoMethodError + $stderr.puts "Sorry, your database adapter is not supported yet. Feel free to submit a patch." end desc "Retrieves the current schema version number" @@ -216,12 +222,27 @@ db_namespace = namespace :db do desc "Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)" task setup: ["db:schema:load_if_ruby", "db:structure:load_if_sql", :seed] + desc "Runs setup if database does not exist, or runs migrations if it does" + task prepare: :load_config do + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + db_namespace["migrate"].invoke + rescue ActiveRecord::NoDatabaseError + db_namespace["setup"].invoke + end + end + desc "Loads the seed data from db/seeds.rb" - task :seed do + task seed: :load_config do db_namespace["abort_if_pending_migrations"].invoke ActiveRecord::Tasks::DatabaseTasks.load_seed end + namespace :seed do + desc "Truncates tables of each database for current environment and loads the seeds" + task replant: [:load_config, :truncate_all, :seed] + end + namespace :fixtures do desc "Loads fixtures into the current environment's database. Load specific fixtures using FIXTURES=x,y. Load from subdirectory in test/fixtures using FIXTURES_DIR=z. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." task load: :load_config do @@ -274,11 +295,10 @@ db_namespace = namespace :db do desc "Creates a db/schema.rb file that is portable against any DB supported by Active Record" task dump: :load_config do require "active_record/schema_dumper" - - ActiveRecord::DatabaseConfigurations.configs_for(Rails.env) do |spec_name, config| - filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(spec_name, :ruby) + 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(config) + ActiveRecord::Base.establish_connection(db_config.config) ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) end end @@ -298,15 +318,22 @@ db_namespace = namespace :db do namespace :cache do desc "Creates a db/schema_cache.yml file." 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) + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config.spec_name) + ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache( + ActiveRecord::Base.connection, + filename, + ) + end end desc "Clears a db/schema_cache.yml file." task clear: :load_config do - filename = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema_cache.yml") - rm_f filename, verbose: false + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config.spec_name) + rm_f filename, verbose: false + end end end end @@ -314,11 +341,10 @@ db_namespace = namespace :db do namespace :structure do desc "Dumps the database structure to db/structure.sql. Specify another file with SCHEMA=db/my_structure.sql" task dump: :load_config do - ActiveRecord::DatabaseConfigurations.configs_for(Rails.env) do |spec_name, config| - ActiveRecord::Base.establish_connection(config) - filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(spec_name, :sql) - ActiveRecord::Tasks::DatabaseTasks.structure_dump(config, filename) - + 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 @@ -353,25 +379,31 @@ db_namespace = namespace :db do # desc "Recreate the test database from an existent schema.rb file" task load_schema: %w(db:test:purge) 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"], "test" - ensure - if should_reconnect - ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations[ActiveRecord::Tasks::DatabaseTasks.env]) - end + should_reconnect = ActiveRecord::Base.connection_pool.active_connection? + ActiveRecord::Schema.verbose = false + 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.default_hash(ActiveRecord::Tasks::DatabaseTasks.env)) 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"], "test" + 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(load_config check_protected_environments) do - ActiveRecord::Tasks::DatabaseTasks.purge ActiveRecord::Base.configurations["test"] + 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' @@ -395,6 +427,10 @@ namespace :railties do if railtie.respond_to?(:paths) && (path = railtie.paths["db/migrate"].first) railties[railtie.railtie_name] = path end + + unless ENV["MIGRATIONS_PATH"].blank? + railties[railtie.railtie_name] = railtie.root + ENV["MIGRATIONS_PATH"] + end end on_skip = Proc.new do |name, migration| diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 2f43d005f3..1312bf6f91 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -13,33 +13,37 @@ module ActiveRecord 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 - name = name.to_s - ar._reflections = ar._reflections.except(name).merge!(name => reflection) - end + def add_aggregate_reflection(ar, name, reflection) + ar.aggregate_reflections = ar.aggregate_reflections.merge(-name.to_s => reflection) + 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 @@ -174,28 +178,24 @@ module ActiveRecord scope ? [scope] : [] end - def build_join_constraint(table, foreign_table) - key = join_keys.key - foreign_key = join_keys.foreign_key - - constraint = table[key].eq(foreign_table[foreign_key]) - - if klass.finder_needs_type_condition? - table.create_and([constraint, klass.send(:type_condition, table)]) - else - constraint - end - end - - def join_scope(table, foreign_klass) + def join_scope(table, foreign_table, foreign_klass) predicate_builder = predicate_builder(table) scope_chain_items = join_scopes(table, predicate_builder) klass_scope = klass_join_scope(table, predicate_builder) + key = join_keys.key + foreign_key = join_keys.foreign_key + + klass_scope.where!(table[key].eq(foreign_table[foreign_key])) + if type klass_scope.where!(type => foreign_klass.polymorphic_name) end + if klass.finder_needs_type_condition? + klass_scope.where!(klass.send(:type_condition, table)) + end + scope_chain_items.inject(klass_scope, &:merge!) end @@ -417,7 +417,7 @@ module ActiveRecord class AssociationReflection < MacroReflection #:nodoc: def compute_class(name) if polymorphic? - raise ArgumentError, "Polymorphic association does not support to compute class." + raise ArgumentError, "Polymorphic associations do not support computing the class." end active_record.send(:compute_type, name) end diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 3c66d2f8be..cd62b0b881 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -5,7 +5,7 @@ module ActiveRecord class Relation MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_outer_joins, :references, - :extending, :unscope] + :extending, :unscope, :optimizer_hints, :annotate] SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :skip_query_cache] @@ -43,6 +43,17 @@ module ActiveRecord klass.arel_attribute(name, table) end + def bind_attribute(name, value) # :nodoc: + if reflection = klass._reflect_on_association(name) + name = reflection.foreign_key + value = value.read_attribute(reflection.klass.primary_key) unless value.nil? + end + + 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. # @@ -56,7 +67,8 @@ module ActiveRecord # user = users.new { |user| user.name = 'Oscar' } # user.name # => Oscar def new(attributes = nil, &block) - scoping { klass.new(scope_for_create(attributes), &block) } + block = _deprecated_scope_block("new", &block) + scoping { klass.new(attributes, &block) } end alias build new @@ -84,7 +96,8 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| create(attr, &block) } else - scoping { klass.create(scope_for_create(attributes), &block) } + block = _deprecated_scope_block("create", &block) + scoping { klass.create(attributes, &block) } end end @@ -98,7 +111,8 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| create!(attr, &block) } else - scoping { klass.create!(scope_for_create(attributes), &block) } + block = _deprecated_scope_block("create!", &block) + scoping { klass.create!(attributes, &block) } end end @@ -165,7 +179,7 @@ module ActiveRecord # 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. + # 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 @@ -175,7 +189,7 @@ module ActiveRecord # # * 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 + # 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, @@ -183,6 +197,10 @@ module ActiveRecord # if a DELETE between those two statements is run by another client. But for most applications, # that's a significantly less likely condition to hit. # * It relies on exception handling to handle control flow, which may be marginally slower. + # * The primary key may auto-increment on each create, even if it fails. This can accelerate + # the problem of running out of integers, if the underlying table is still stuck on a primary + # key of type int (note: All Rails apps since 5.1+ have defaulted to bigint, which is not liable + # to this problem). # # This method will return a record if all given attributes are covered by unique constraints # (unless the INSERT -> DELETE -> SELECT race condition is triggered), but if creation was attempted @@ -217,7 +235,7 @@ 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 @@ -299,6 +317,51 @@ module ActiveRecord @cache_keys[timestamp_column] ||= @klass.collection_cache_key(self, timestamp_column) end + def compute_cache_key(timestamp_column = :updated_at) # :nodoc: + query_signature = ActiveSupport::Digest.hexdigest(to_sql) + key = "#{klass.model_name.cache_key}/query-#{query_signature}" + + if loaded? || distinct_value + size = records.size + if size > 0 + timestamp = max_by(×tamp_column)._read_attribute(timestamp_column) + end + else + collection = eager_loading? ? apply_join_dependency : self + + column = connection.visitor.compile(arel_attribute(timestamp_column)) + select_values = "COUNT(*) AS #{connection.quote_column_name("size")}, MAX(%s) AS timestamp" + + if collection.has_limit_or_offset? + query = collection.select("#{column} AS collection_cache_key_timestamp") + subquery_alias = "subquery_for_cache_key" + subquery_column = "#{subquery_alias}.collection_cache_key_timestamp" + arel = query.build_subquery(subquery_alias, select_values % subquery_column) + else + query = collection.unscope(:order) + query.select_values = [select_values % column] + arel = query.arel + end + + result = connection.select_one(arel, nil) + + if result + column_type = klass.type_for_attribute(timestamp_column) + timestamp = column_type.deserialize(result["timestamp"]) + size = result["size"] + else + timestamp = nil + size = 0 + end + end + + if timestamp + "#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" + else + "#{key}-#{size}" + end + end + # Scope all queries to the current scope. # # Comment.where(post_id: 1).scoping do @@ -309,15 +372,12 @@ module ActiveRecord # Please check unscoped if you want to remove all previous scopes (including # the default_scope) during the execution of a block. def scoping - previous, klass.current_scope = klass.current_scope(true), self - yield - ensure - klass.current_scope = previous + already_in_scope? ? yield : _scoping(self) { yield } end - def _exec_scope(*args, &block) # :nodoc: + def _exec_scope(name, *args, &block) # :nodoc: @delegate_to_klass = true - instance_exec(*args, &block) || self + _scoping(_deprecated_spawn(name)) { instance_exec(*args, &block) || self } ensure @delegate_to_klass = false end @@ -327,6 +387,8 @@ module ActiveRecord # trigger Active Record callbacks or validations. However, values passed to #update_all will still go through # Active Record's normal type casting and serialization. # + # Note: As Active Record callbacks are not triggered, this method will not automatically update +updated_at+/+updated_on+ columns. + # # ==== Parameters # # * +updates+ - A string, array, or hash representing the SET part of an SQL statement. @@ -353,20 +415,79 @@ module ActiveRecord end stmt = Arel::UpdateManager.new + stmt.table(arel.join_sources.empty? ? table : arel.source) + stmt.key = arel_attribute(primary_key) + stmt.take(arel.limit) + stmt.offset(arel.offset) + stmt.order(*arel.orders) + stmt.wheres = arel.constraints + + if updates.is_a?(Hash) + if klass.locking_enabled? && + !updates.key?(klass.locking_column) && + !updates.key?(klass.locking_column.to_sym) + attr = arel_attribute(klass.locking_column) + updates[attr.name] = _increment_attribute(attr) + end + stmt.set _substitute_values(updates) + else + stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name)) + end - stmt.set Arel.sql(@klass.sanitize_sql_for_assignment(updates)) - stmt.table(table) + @klass.connection.update stmt, "#{@klass} Update All" + end - if has_join_values? || offset_value - @klass.connection.join_to_update(stmt, arel, arel_attribute(primary_key)) + def update(id = :all, attributes) # :nodoc: + if id == :all + each { |record| record.update(attributes) } else - stmt.key = arel_attribute(primary_key) - stmt.take(arel.limit) - stmt.order(*arel.orders) - stmt.wheres = arel.constraints + klass.update(id, attributes) end + end - @klass.connection.update stmt, "#{@klass} Update All" + def update_counters(counters) # :nodoc: + touch = counters.delete(:touch) + + updates = {} + counters.each do |counter_name, value| + attr = arel_attribute(counter_name) + updates[attr.name] = _increment_attribute(attr, value) + end + + if touch + names = touch if touch != true + touch_updates = klass.touch_attributes_with_time(*names) + updates.merge!(touch_updates) unless touch_updates.empty? + end + + update_all updates + end + + # Touches all records in the current relation without instantiating records first with the +updated_at+/+updated_on+ attributes + # set to the current time or the time specified. + # This method can be passed attribute names and an optional time argument. + # If attribute names are passed, they are updated along with +updated_at+/+updated_on+ attributes. + # If no time argument is passed, the current time is used as default. + # + # === Examples + # + # # Touch all records + # Person.all.touch_all + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670'" + # + # # 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'" + # + # # 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) + update_all klass.touch_attributes_with_time(*names, time: time) end # Destroys the records by instantiating each @@ -422,13 +543,12 @@ module ActiveRecord end stmt = Arel::DeleteManager.new - stmt.from(table) - - 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 + stmt.from(arel.join_sources.empty? ? table : arel.source) + stmt.key = arel_attribute(primary_key) + stmt.take(arel.limit) + stmt.offset(arel.offset) + stmt.order(*arel.orders) + stmt.wheres = arel.constraints affected = @klass.connection.delete(stmt, "#{@klass} Destroy") @@ -436,6 +556,32 @@ module ActiveRecord affected end + # Finds and destroys all records matching the specified conditions. + # This is short-hand for <tt>relation.where(condition).destroy_all</tt>. + # Returns the collection of objects that were destroyed. + # + # If no record is found, returns empty array. + # + # Person.destroy_by(id: 13) + # Person.destroy_by(name: 'Spartacus', rating: 4) + # Person.destroy_by("published_at < ?", 2.weeks.ago) + def destroy_by(*args) + where(*args).destroy_all + end + + # Finds and deletes all records matching the specified conditions. + # This is short-hand for <tt>relation.where(condition).delete_all</tt>. + # Returns the number of rows affected. + # + # If no record is found, returns <tt>0</tt> as zero rows were affected. + # + # Person.delete_by(id: 13) + # Person.delete_by(name: 'Spartacus', rating: 4) + # Person.delete_by("published_at < ?", 2.weeks.ago) + def delete_by(*args) + where(*args).delete_all + end + # Causes the records to be loaded from the database if they have not # been loaded already. You can use this if for some reason you need # to explicitly load some records before actually using them. The @@ -456,6 +602,7 @@ module ActiveRecord def reset @delegate_to_klass = false + @_deprecated_scope_source = nil @to_sql = @arel = @loaded = @should_eager_load = nil @records = [].freeze @offsets = {} @@ -468,17 +615,16 @@ module ActiveRecord # # => SELECT "users".* FROM "users" WHERE "users"."name" = 'Oscar' def to_sql @to_sql ||= begin - relation = self - - if eager_loading? - apply_join_dependency { |rel, _| relation = rel } - end - - conn = klass.connection - conn.unprepared_statement { - conn.to_sql(relation.arel) - } - 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. @@ -489,10 +635,8 @@ module ActiveRecord where_clause.to_h(relation_table_name) end - def scope_for_create(attributes = nil) - scope = where_values_hash.merge!(create_with_value.stringify_keys) - scope.merge!(attributes) if attributes - scope + def scope_for_create + where_values_hash.merge!(create_with_value.stringify_keys) end # Returns true if relation needs eager loading. @@ -557,7 +701,7 @@ module ActiveRecord ActiveRecord::Associations::AliasTracker.create(connection, table.name, joins) end - def preload_associations(records) + def preload_associations(records) # :nodoc: preload = preload_values preload += includes_values unless eager_loading? preloader = nil @@ -567,17 +711,62 @@ module ActiveRecord end end + attr_reader :_deprecated_scope_source # :nodoc: + protected + attr_writer :_deprecated_scope_source # :nodoc: def load_records(records) @records = records.freeze @loaded = true end + def null_relation? # :nodoc: + is_a?(NullRelation) + end + private + def already_in_scope? + @delegate_to_klass && begin + scope = klass.current_scope(true) + scope && !scope._deprecated_scope_source + end + end + + def _deprecated_spawn(name) + spawn.tap { |scope| scope._deprecated_scope_source = name } + end + + def _deprecated_scope_block(name, &block) + -> record do + klass.current_scope = _deprecated_spawn(name) + yield record if block_given? + end + end + + def _scoping(scope) + previous, klass.current_scope = klass.current_scope(true), scope + yield + ensure + klass.current_scope = previous + end + + def _substitute_values(values) + values.map do |name, value| + attr = arel_attribute(name) + unless Arel.arel_node?(value) + type = klass.type_for_attribute(attr.name) + value = predicate_builder.build_bind_attribute(attr.name, type.cast(value)) + end + [attr, value] + end + end - def has_join_values? - joins_values.any? || left_outer_joins_values.any? + def _increment_attribute(attribute, value = 1) + bind = predicate_builder.build_bind_attribute(attribute.name, value.abs) + expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attribute), 0) + expr = value < 0 ? expr - bind : expr + bind + expr.expr end def exec_queries(&block) @@ -585,9 +774,10 @@ module ActiveRecord @records = if eager_loading? apply_join_dependency do |relation, join_dependency| - if ActiveRecord::NullRelation === relation + if relation.null_relation? [] else + relation = join_dependency.apply_column_aliases(relation) rows = connection.select_all(relation.arel, "SQL") join_dependency.instantiate(rows, &block) end.freeze diff --git a/activerecord/lib/active_record/relation/batches.rb b/activerecord/lib/active_record/relation/batches.rb index ec4bb06c57..9c579843b1 100644 --- a/activerecord/lib/active_record/relation/batches.rb +++ b/activerecord/lib/active_record/relation/batches.rb @@ -251,8 +251,9 @@ module ActiveRecord end end - bind = primary_key_bind(primary_key_offset) - batch_relation = relation.where(arel_attribute(primary_key).gt(bind)) + batch_relation = relation.where( + bind_attribute(primary_key, primary_key_offset) { |attr, bind| attr.gt(bind) } + ) end end @@ -265,15 +266,11 @@ module ActiveRecord end def apply_start_limit(relation, start) - relation.where(arel_attribute(primary_key).gteq(primary_key_bind(start))) + relation.where(bind_attribute(primary_key, start) { |attr, bind| attr.gteq(bind) }) end def apply_finish_limit(relation, finish) - relation.where(arel_attribute(primary_key).lteq(primary_key_bind(finish))) - end - - def primary_key_bind(value) - predicate_builder.build_bind_attribute(primary_key, value) + relation.where(bind_attribute(primary_key, finish) { |attr, bind| attr.lteq(bind) }) end def batch_order diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index f215c95f51..801e312658 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -41,15 +41,13 @@ module ActiveRecord def count(column_name = nil) if block_given? 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." + raise ArgumentError, "Column name argument is not supported when a block is passed." end - return super() + super() + else + calculate(:count, column_name) end - - calculate(:count, column_name) end # Calculates the average value on a given column. Returns +nil+ if there's @@ -86,15 +84,13 @@ module ActiveRecord 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." + raise ArgumentError, "Column name argument is not supported when a block is passed." end - return super() + super() + else + calculate(:sum, column_name) end - - calculate(:sum, column_name) end # This calculates aggregate values in the given column. Methods for #count, #sum, #average, @@ -133,11 +129,12 @@ module ActiveRecord relation = apply_join_dependency if operation.to_s.downcase == "count" - relation.distinct! - # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT - if (column_name == :all || column_name.nil?) && select_values.empty? - relation.order_values = [] + unless distinct_value || distinct_select?(column_name || select_for_count) + relation.distinct! + relation.select_values = [ klass.primary_key || table[Arel.star] ] end + # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT + relation.order_values = [] end relation.calculate(operation, column_name) @@ -190,11 +187,9 @@ module ActiveRecord relation = apply_join_dependency relation.pluck(*column_names) else - enforce_raw_sql_whitelist(column_names) + klass.disallow_raw_sql!(column_names) relation = spawn - relation.select_values = column_names.map { |cn| - @klass.has_attribute?(cn) || @klass.attribute_alias?(cn) ? arel_attribute(cn) : cn - } + relation.select_values = column_names result = skip_query_cache_if_necessary { klass.connection.select_all(relation.arel, nil) } result.cast_values(klass.attribute_types) end @@ -227,7 +222,6 @@ module ActiveRecord end private - def has_include?(column_name) eager_loading? || (includes_values.present? && column_name && column_name != :all) end @@ -242,10 +236,12 @@ module ActiveRecord if operation == "count" column_name ||= select_for_count if column_name == :all - if distinct && (group_values.any? || select_values.empty? && order_values.empty?) + if !distinct + distinct = distinct_select?(select_for_count) if group_values.empty? + elsif group_values.any? || select_values.empty? && order_values.empty? column_name = primary_key end - elsif column_name =~ /\s*DISTINCT[\s(]+/i + elsif distinct_select?(column_name) distinct = nil end end @@ -257,6 +253,10 @@ module ActiveRecord end end + def distinct_select?(column_name) + column_name.is_a?(::String) && /\bDISTINCT[\s(]/i.match?(column_name) + end + def aggregate_column(column_name) return column_name if Arel::Expressions === column_name @@ -308,25 +308,22 @@ module ActiveRecord end def execute_grouped_calculation(operation, column_name, distinct) #:nodoc: - group_attrs = group_values + group_fields = group_values - if group_attrs.first.respond_to?(:to_sym) - association = @klass._reflect_on_association(group_attrs.first) - associated = group_attrs.size == 1 && association && association.belongs_to? # only count belongs_to associations - group_fields = Array(associated ? association.foreign_key : group_attrs) - else - group_fields = group_attrs + if group_fields.size == 1 && group_fields.first.respond_to?(:to_sym) + association = klass._reflect_on_association(group_fields.first) + associated = association && association.belongs_to? # only count belongs_to associations + group_fields = Array(association.foreign_key) if associated end group_fields = arel_columns(group_fields) - group_aliases = group_fields.map { |field| column_alias_for(field) } + group_aliases = group_fields.map { |field| + field = connection.visitor.compile(field) if Arel.arel_node?(field) + column_alias_for(field.to_s.downcase) + } group_columns = group_aliases.zip(group_fields) - if operation == "count" && column_name == :all - aggregate_alias = "count_all" - else - aggregate_alias = column_alias_for([operation, column_name].join(" ")) - end + aggregate_alias = column_alias_for("#{operation}_#{column_name.to_s.downcase}") select_values = [ operation_over_aggregate_column( @@ -345,7 +342,7 @@ module ActiveRecord } relation = except(:group).distinct!(false) - relation.group_values = group_fields + relation.group_values = group_aliases relation.select_values = select_values calculated_data = skip_query_cache_if_necessary { @klass.connection.select_all(relation.arel, nil) } @@ -371,25 +368,23 @@ module ActiveRecord end] end - # Converts the given keys to the value that the database adapter returns as + # Converts the given field to the value that the database adapter returns as # a usable column name: # # column_alias_for("users.id") # => "users_id" # column_alias_for("sum(id)") # => "sum_id" # column_alias_for("count(distinct users.id)") # => "count_distinct_users_id" # column_alias_for("count(*)") # => "count_all" - def column_alias_for(keys) - if keys.respond_to? :name - keys = "#{keys.relation.name}.#{keys.name}" - end + def column_alias_for(field) + return field if field.match?(/\A\w{,#{connection.table_alias_length}}\z/) - table_name = keys.to_s.downcase - table_name.gsub!(/\*/, "all") - table_name.gsub!(/\W+/, " ") - table_name.strip! - table_name.gsub!(/ +/, "_") + column_alias = +field + column_alias.gsub!(/\*/, "all") + column_alias.gsub!(/\W+/, " ") + column_alias.strip! + column_alias.gsub!(/ +/, "_") - @klass.connection.table_alias_for(table_name) + connection.table_alias_for(column_alias) end def type_for(field, &block) @@ -401,7 +396,7 @@ module ActiveRecord case operation when "count" then value.to_i when "sum" then type.deserialize(value || 0) - when "average" then value.respond_to?(:to_d) ? value.to_d : value + when "average" then value&.respond_to?(:to_d) ? value.to_d : value else type.deserialize(value) end end @@ -417,16 +412,17 @@ module ActiveRecord def build_count_subquery(relation, column_name, distinct) if column_name == :all + column_alias = Arel.star relation.select_values = [ Arel.sql(FinderMethods::ONE_AS_ONE) ] unless distinct else column_alias = Arel.sql("count_column") relation.select_values = [ aggregate_column(column_name).as(column_alias) ] end - subquery = relation.arel.as(Arel.sql("subquery_for_count")) - select_value = operation_over_aggregate_column(column_alias || Arel.star, "count", false) + subquery_alias = Arel.sql("subquery_for_count") + select_value = operation_over_aggregate_column(column_alias, "count", false) - Arel::SelectManager.new(subquery).project(select_value) + relation.build_subquery(subquery_alias, select_value) end end end diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index 488f71cdde..7a53a9d1c7 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "mutex_m" + module ActiveRecord module Delegation # :nodoc: module DelegateCache # :nodoc: @@ -17,7 +19,8 @@ module ActiveRecord delegate = Class.new(klass) { include ClassSpecificRelation } - mangled_name = klass.name.gsub("::".freeze, "_".freeze) + include_relation_methods(delegate) + mangled_name = klass.name.gsub("::", "_") const_set mangled_name, delegate private_constant mangled_name @@ -29,8 +32,46 @@ module ActiveRecord child_class.initialize_relation_delegate_cache super end + + def generate_relation_method(method) + generated_relation_methods.generate_method(method) + end + + protected + def include_relation_methods(delegate) + superclass.include_relation_methods(delegate) unless base_class? + delegate.include generated_relation_methods + end + + private + def generated_relation_methods + @generated_relation_methods ||= GeneratedRelationMethods.new + end end + class GeneratedRelationMethods < Module # :nodoc: + include Mutex_m + + def generate_method(method) + synchronize do + return if method_defined?(method) + + if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{method}(*args, &block) + scoping { klass.#{method}(*args, &block) } + end + RUBY + else + define_method(method) do |*args, &block| + scoping { klass.public_send(method, *args, &block) } + end + end + end + end + end + private_constant :GeneratedRelationMethods + extend ActiveSupport::Concern # This module creates compiled delegation methods dynamically at runtime, which makes @@ -48,49 +89,18 @@ module ActiveRecord module ClassSpecificRelation # :nodoc: extend ActiveSupport::Concern - included do - @delegation_mutex = Mutex.new - end - module ClassMethods # :nodoc: def name superclass.name end - - def delegate_to_scoped_klass(method) - @delegation_mutex.synchronize do - return if method_defined?(method) - - if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method}(*args, &block) - scoping { @klass.#{method}(*args, &block) } - end - RUBY - else - define_method method do |*args, &block| - scoping { @klass.public_send(method, *args, &block) } - end - end - end - end end private def method_missing(method, *args, &block) if @klass.respond_to?(method) - self.class.delegate_to_scoped_klass(method) + @klass.generate_relation_method(method) scoping { @klass.public_send(method, *args, &block) } - 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) - 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 end @@ -111,7 +121,7 @@ module ActiveRecord private def respond_to_missing?(method, _) - super || @klass.respond_to?(method) || arel.respond_to?(method) + super || @klass.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 f7613a187d..9450e4d3c5 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -7,8 +7,8 @@ module ActiveRecord ONE_AS_ONE = "1 AS one" # Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]). - # If one or more records can not be found for the requested ids, then RecordNotFound will be raised. If the primary key - # is an integer, find by id coerces its arguments using +to_i+. + # If one or more records cannot be found for the requested ids, then ActiveRecord::RecordNotFound will be raised. + # If the primary key is an integer, find by id coerces its arguments by using +to_i+. # # Person.find(1) # returns the object for ID = 1 # Person.find("1") # returns the object for ID = 1 @@ -79,17 +79,12 @@ module ActiveRecord # Post.find_by "published_at < ?", 2.weeks.ago def find_by(arg, *args) where(arg, *args).take - rescue ::RangeError - nil end # Like #find_by, except that if no record is found, raises # an ActiveRecord::RecordNotFound error. def find_by!(arg, *args) where(arg, *args).take! - rescue ::RangeError - raise RecordNotFound.new("Couldn't find #{@klass.name} with an out of range value", - @klass.name, @klass.primary_key) end # Gives a record (or N records if a parameter is supplied) without any implied @@ -319,9 +314,7 @@ module ActiveRecord relation = construct_relation_for_exists(conditions) - skip_query_cache_if_necessary { connection.select_value(relation.arel, "#{name} Exists") } ? true : false - rescue ::RangeError - false + skip_query_cache_if_necessary { connection.select_one(relation.arel, "#{name} Exists?") } ? true : false end # This method is called whenever no records are found with either a single @@ -338,14 +331,14 @@ module ActiveRecord name = @klass.name if ids.nil? - error = "Couldn't find #{name}".dup + error = +"Couldn't find #{name}" error << " with#{conditions}" if conditions 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}': ".dup + error = +"Couldn't find all #{name.pluralize} with '#{key}': " 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) @@ -359,11 +352,17 @@ module ActiveRecord end def construct_relation_for_exists(conditions) - relation = except(:select, :distinct, :order)._select!(ONE_AS_ONE).limit!(1) + conditions = sanitize_forbidden_attributes(conditions) + + if distinct_value && offset_value + relation = limit(1) + else + relation = except(:select, :distinct, :order)._select!(ONE_AS_ONE).limit!(1) + end case conditions when Array, Hash - relation.where!(conditions) + relation.where!(conditions) unless conditions.empty? else relation.where!(primary_key => conditions) unless conditions == :none end @@ -371,16 +370,8 @@ module ActiveRecord relation end - def construct_join_dependency - including = eager_load_values + includes_values - joins = joins_values.select { |join| join.is_a?(Arel::Nodes::Join) } - ActiveRecord::Associations::JoinDependency.new( - klass, table, including, alias_tracker(joins) - ) - end - - def apply_join_dependency(eager_loading: true) - join_dependency = construct_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 eager_loading && !using_limitable_reflections?(join_dependency.reflections) @@ -392,7 +383,6 @@ module ActiveRecord end if block_given? - relation._select!(join_dependency.aliases.columns) yield relation, join_dependency else relation @@ -401,7 +391,7 @@ module ActiveRecord def limited_ids_for(relation) values = @klass.connection.columns_for_distinct( - connection.column_name_from_arel_node(arel_attribute(primary_key)), + connection.visitor.compile(arel_attribute(primary_key)), relation.order_values ) @@ -419,7 +409,7 @@ module ActiveRecord raise UnknownPrimaryKey.new(@klass) if primary_key.nil? expects_array = ids.first.kind_of?(Array) - return ids.first if expects_array && ids.first.empty? + return [] if expects_array && ids.first.empty? ids = ids.flatten.compact.uniq @@ -435,9 +425,6 @@ module ActiveRecord else find_some(ids) end - rescue ::RangeError - 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) @@ -553,8 +540,8 @@ module ActiveRecord end def ordered_relation - if order_values.empty? && primary_key - order(arel_attribute(primary_key).asc) + if order_values.empty? && (implicit_order_column || primary_key) + order(arel_attribute(implicit_order_column || primary_key).asc) else self end diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index 25510d4a57..6bb77b355c 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -117,20 +117,14 @@ module ActiveRecord if other.klass == relation.klass relation.joins!(*other.joins_values) else - alias_tracker = nil - joins_dependency = other.joins_values.map do |join| + associations, others = other.joins_values.partition do |join| case join - when Hash, Symbol, Array - alias_tracker ||= other.alias_tracker - ActiveRecord::Associations::JoinDependency.new( - other.klass, other.table, join, alias_tracker - ) - else - join + when Hash, Symbol, Array; true end end - relation.joins!(*joins_dependency) + join_dependency = other.construct_join_dependency(associations) + relation.joins!(join_dependency, *others) end end @@ -140,30 +134,19 @@ module ActiveRecord if other.klass == relation.klass relation.left_outer_joins!(*other.left_outer_joins_values) else - alias_tracker = nil - joins_dependency = other.left_outer_joins_values.map do |join| - case join - when Hash, Symbol, Array - alias_tracker ||= other.alias_tracker - ActiveRecord::Associations::JoinDependency.new( - other.klass, other.table, join, alias_tracker - ) - else - join - end - end - - relation.left_outer_joins!(*joins_dependency) + associations = other.left_outer_joins_values + join_dependency = other.construct_join_dependency(associations) + relation.joins!(join_dependency) end end def merge_multi_values if other.reordering_value # override any order specified in the original relation - relation.reorder! 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 extensions = other.extensions - relation.extensions @@ -179,9 +162,7 @@ module ActiveRecord end def merge_clauses - if relation.from_clause.empty? && !other.from_clause.empty? - relation.from_clause = other.from_clause - end + relation.from_clause = other.from_clause if replace_from_clause? where_clause = relation.where_clause.merge(other.where_clause) relation.where_clause = where_clause unless where_clause.empty? @@ -189,6 +170,11 @@ module ActiveRecord having_clause = relation.having_clause.merge(other.having_clause) relation.having_clause = having_clause unless having_clause.empty? end + + def replace_from_clause? + relation.from_clause.empty? && !other.from_clause.empty? && + relation.klass.base_class == other.klass.base_class + end end end end diff --git a/activerecord/lib/active_record/relation/predicate_builder.rb b/activerecord/lib/active_record/relation/predicate_builder.rb index 7a0edcbc33..240de3bb69 100644 --- a/activerecord/lib/active_record/relation/predicate_builder.rb +++ b/activerecord/lib/active_record/relation/predicate_builder.rb @@ -27,7 +27,7 @@ module ActiveRecord key else key = key.to_s - key.split(".".freeze).first if key.include?(".".freeze) + key.split(".").first if key.include?(".") end end.compact end @@ -48,7 +48,12 @@ 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 def build_bind_attribute(column_name, value) @@ -85,20 +90,21 @@ module ActiveRecord 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) + values = value.nil? ? [nil] : Array.wrap(value) + if mapping.length == 1 || values.empty? + column_name, aggr_attr = mapping.first + values = values.map do |object| + object.respond_to?(aggr_attr) ? object.public_send(aggr_attr) : object + end + build(table.arel_attribute(column_name), values) + else + queries = values.map do |object| + mapping.map do |field_attr, aggregate_attr| + build(table.arel_attribute(field_attr), object.try!(aggregate_attr)) + end.reduce(&:and) + end + queries.reduce(&:or) end - queries.reduce(&:or) - # FIXME: Deprecate this and provide a public API to force equality - elsif (value.is_a?(Range) || value.is_a?(Array)) && - table.type(key.to_s).respond_to?(:subtype) - BasicObjectHandler.new(self).call(table.arel_attribute(key), value) else build(table.arel_attribute(key), value) end @@ -114,11 +120,11 @@ module ActiveRecord def convert_dot_notation_to_hash(attributes) dot_notation = attributes.select do |k, v| - k.include?(".".freeze) && !v.is_a?(Hash) + k.include?(".") && !v.is_a?(Hash) end dot_notation.each_key do |key| - table_name, column_name = key.split(".".freeze) + table_name, column_name = key.split(".") value = attributes.delete(key) attributes[table_name] ||= {} 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 64bf83e3c1..ee2ece1560 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_support/core_ext/array/extract" + module ActiveRecord class PredicateBuilder class ArrayHandler # :nodoc: @@ -11,18 +13,18 @@ module ActiveRecord return attribute.in([]) if value.empty? values = value.map { |x| x.is_a?(Base) ? x.id : x } - nils, values = values.partition(&:nil?) - ranges, values = values.partition { |v| v.is_a?(Range) } + 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 - bind_values = values.map do |v| + values.map! do |v| predicate_builder.build_bind_attribute(attribute.name, v) end - attribute.in(bind_values) + values.empty? ? NullPredicate : attribute.in(values) end unless nils.empty? 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 44bb2c7ab6..2ea27c8490 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb @@ -3,11 +3,7 @@ module ActiveRecord class PredicateBuilder class RangeHandler # :nodoc: - class RangeWithBinds < Struct.new(:begin, :end) - def exclude_end? - false - end - end + RangeWithBinds = Struct.new(:begin, :end, :exclude_end?) def initialize(predicate_builder) @predicate_builder = predicate_builder @@ -16,22 +12,7 @@ module ActiveRecord def call(attribute, value) 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(end_bind) - else - attribute.lteq(end_bind) - end - elsif end_bind.value.infinity? - attribute.gteq(begin_bind) - elsif value.exclude_end? - attribute.gteq(begin_bind).and(attribute.lt(end_bind)) - else - attribute.between(RangeWithBinds.new(begin_bind, end_bind)) - end + attribute.between(RangeWithBinds.new(begin_bind, end_bind, value.exclude_end?)) end private diff --git a/activerecord/lib/active_record/relation/query_attribute.rb b/activerecord/lib/active_record/relation/query_attribute.rb index f64bd30d38..cd18f27330 100644 --- a/activerecord/lib/active_record/relation/query_attribute.rb +++ b/activerecord/lib/active_record/relation/query_attribute.rb @@ -18,24 +18,31 @@ module ActiveRecord end def nil? - !value_before_type_cast.is_a?(StatementCache::Substitute) && - (value_before_type_cast.nil? || value_for_database.nil?) + unless value_before_type_cast.is_a?(StatementCache::Substitute) + value_before_type_cast.nil? || + type.respond_to?(:subtype, true) && value_for_database.nil? + end + rescue ::RangeError end - def boundable? - return @_boundable if defined?(@_boundable) - nil? - @_boundable = true + def infinite? + infinity?(value_before_type_cast) || infinity?(value_for_database) rescue ::RangeError - @_boundable = false end - def infinity? - _infinity?(value_before_type_cast) || boundable? && _infinity?(value_for_database) + def unboundable? + if defined?(@_unboundable) + @_unboundable + else + value_for_database unless value_before_type_cast.is_a?(StatementCache::Substitute) + @_unboundable = nil + end + rescue ::RangeError + @_unboundable = type.cast(value_before_type_cast) <=> 0 end private - def _infinity?(value) + def infinity?(value) value.respond_to?(:infinite?) && value.infinite? end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 4e60863e52..90b5e9a118 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -100,7 +100,7 @@ module ActiveRecord # # === conditions # - # If you want to add conditions to your included models you'll have + # If you want to add string conditions to your included models, you'll have # to explicitly reference them. For example: # # User.includes(:posts).where('posts.name = ?', 'example') @@ -111,6 +111,12 @@ module ActiveRecord # # Note that #includes works with association names while #references needs # the actual table name. + # + # If you pass the conditions via hash, you don't need to call #references + # explicitly, as #where references the tables for you. For example, this + # will work correctly: + # + # User.includes(:posts).where(posts: { name: 'example' }) def includes(*args) check_if_method_has_arguments!(:includes, args) spawn.includes!(*args) @@ -154,6 +160,19 @@ module ActiveRecord self end + # Extracts a named +association+ from the relation. The named association is first preloaded, + # then the individual association records are collected from the relation. Like so: + # + # account.memberships.extract_associated(:user) + # # => Returns collection of User records + # + # This is short-hand for: + # + # account.memberships.preload(:user).collect(&:user) + def extract_associated(association) + preload(association).collect(&association) + end + # Use to indicate that the given +table_names+ are referenced by an SQL string, # and should therefore be JOINed in any query rather than loaded separately. # This method only works in conjunction with #includes. @@ -233,13 +252,31 @@ module ActiveRecord def _select!(*fields) # :nodoc: fields.reject!(&:blank?) fields.flatten! - fields.map! do |field| - klass.attribute_alias?(field) ? klass.attribute_alias(field).to_sym : field - end self.select_values += fields self end + # Allows you to change a previously set select statement. + # + # Post.select(:title, :body) + # # SELECT `posts`.`title`, `posts`.`body` FROM `posts` + # + # Post.select(:title, :body).reselect(:created_at) + # # SELECT `posts`.`created_at` FROM `posts` + # + # This is short-hand for <tt>unscope(:select).select(fields)</tt>. + # Note that we're unscoping the entire select statement. + def reselect(*args) + check_if_method_has_arguments!(:reselect, args) + spawn.reselect!(*args) + end + + # Same as #reselect but operates on relation in-place instead of copying. + def reselect!(*args) # :nodoc: + self.select_values = args + self + end + # Allows to specify a group attribute: # # User.group(:name) @@ -328,8 +365,8 @@ module ActiveRecord end VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock, - :limit, :offset, :joins, :left_outer_joins, - :includes, :from, :readonly, :having]) + :limit, :offset, :joins, :left_outer_joins, :annotate, + :includes, :from, :readonly, :having, :optimizer_hints]) # Removes an unwanted relation that is already defined on a chain of relations. # This is useful when passing around chains of relations and would like to @@ -880,6 +917,29 @@ module ActiveRecord self end + # Specify optimizer hints to be used in the SELECT statement. + # + # Example (for MySQL): + # + # Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)") + # # SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics` + # + # Example (for PostgreSQL with pg_hint_plan): + # + # Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)") + # # SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics" + def optimizer_hints(*args) + check_if_method_has_arguments!(:optimizer_hints, args) + spawn.optimizer_hints!(*args) + end + + def optimizer_hints!(*args) # :nodoc: + args.flatten! + + self.optimizer_hints_values += args + self + end + # Reverse the existing order clause on the relation. # # User.order('name ASC').reverse_order # generated SQL has 'ORDER BY name DESC' @@ -894,8 +954,8 @@ module ActiveRecord self end - def skip_query_cache! # :nodoc: - self.skip_query_cache_value = true + def skip_query_cache!(value = true) # :nodoc: + self.skip_query_cache_value = value self end @@ -904,25 +964,58 @@ module ActiveRecord self end + # Adds an SQL comment to queries generated from this relation. For example: + # + # User.annotate("selecting user names").select(:name) + # # SELECT "users"."name" FROM "users" /* selecting user names */ + # + # User.annotate("selecting", "user", "names").select(:name) + # # SELECT "users"."name" FROM "users" /* selecting */ /* user */ /* names */ + # + # The SQL block comment delimiters, "/*" and "*/", will be added automatically. + def annotate(*args) + check_if_method_has_arguments!(:annotate, args) + spawn.annotate!(*args) + end + + # Like #annotate, but modifies relation in place. + def annotate!(*args) # :nodoc: + self.annotate_values += args + self + end + # Returns the Arel object associated with the relation. def arel(aliases = nil) # :nodoc: @arel ||= build_arel(aliases) end + def construct_join_dependency(associations) # :nodoc: + ActiveRecord::Associations::JoinDependency.new( + klass, table, associations + ) + end + protected + def build_subquery(subquery_alias, select_value) # :nodoc: + subquery = except(:optimizer_hints).arel.as(subquery_alias) + + Arel::SelectManager.new(subquery).project(select_value).tap do |arel| + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? + end + end + + private # Returns a relation value with a given name - def get_value(name) # :nodoc: + def get_value(name) @values.fetch(name, DEFAULT_VALUES[name]) end # Sets the relation value with the given name - def set_value(name, value) # :nodoc: + def set_value(name, value) assert_mutability! @values[name] = value end - private - def assert_mutability! raise ImmutableRelation if @loaded raise ImmutableRelation if defined?(@arel) && @arel @@ -931,14 +1024,17 @@ module ActiveRecord def build_arel(aliases) arel = Arel::SelectManager.new(table) - aliases = build_joins(arel, joins_values.flatten, aliases) unless joins_values.empty? - build_left_outer_joins(arel, left_outer_joins_values.flatten, aliases) unless left_outer_joins_values.empty? + if !joins_values.empty? + build_joins(arel, joins_values.flatten, aliases) + elsif !left_outer_joins_values.empty? + build_left_outer_joins(arel, left_outer_joins_values.flatten, aliases) + end arel.where(where_clause.ast) unless where_clause.empty? arel.having(having_clause.ast) unless having_clause.empty? if limit_value limit_attribute = ActiveModel::Attribute.with_cast_value( - "LIMIT".freeze, + "LIMIT", connection.sanitize_limit(limit_value), Type.default_value, ) @@ -946,7 +1042,7 @@ module ActiveRecord end if offset_value offset_attribute = ActiveModel::Attribute.with_cast_value( - "OFFSET".freeze, + "OFFSET", offset_value.to_i, Type.default_value, ) @@ -958,9 +1054,11 @@ module ActiveRecord build_select(arel) + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? arel.distinct(distinct_value) arel.from(build_from) unless from_clause.empty? arel.lock(lock_value) if lock_value + arel.comment(*annotate_values) unless annotate_values.empty? arel end @@ -980,22 +1078,28 @@ module ActiveRecord end end - def build_left_outer_joins(manager, outer_joins, aliases) - buckets = outer_joins.group_by do |join| - case join + def valid_association_list(associations) + associations.each do |association| + case association when Hash, Symbol, Array - :association_join - when ActiveRecord::Associations::JoinDependency - :stashed_join + # valid else raise ArgumentError, "only Hash, Symbol and Array are allowed" end end + end + def build_left_outer_joins(manager, outer_joins, aliases) + buckets = { association_join: valid_association_list(outer_joins) } build_join_query(manager, buckets, Arel::Nodes::OuterJoin, aliases) end def build_joins(manager, joins, aliases) + unless left_outer_joins_values.empty? + left_joins = valid_association_list(left_outer_joins_values.flatten) + joins << construct_join_dependency(left_joins) + end + buckets = joins.group_by do |join| case join when String @@ -1017,19 +1121,17 @@ module ActiveRecord 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(string_joins) alias_tracker = alias_tracker(join_list, aliases) - join_dependency = ActiveRecord::Associations::JoinDependency.new( - klass, table, association_joins, alias_tracker - ) + join_dependency = construct_join_dependency(association_joins) - joins = join_dependency.join_constraints(stashed_association_joins, join_type) + joins = join_dependency.join_constraints(stashed_joins, join_type, alias_tracker) joins.each { |join| manager.from(join) } manager.join_sources.concat(join_list) @@ -1055,17 +1157,36 @@ module ActiveRecord end def arel_columns(columns) - columns.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) + columns.flat_map do |field| + case field + when Symbol + field = field.to_s + arel_column(field, &connection.method(:quote_table_name)) + when String + arel_column(field, &:itself) + when Proc + field.call else field end end end + def arel_column(field) + field = klass.attribute_alias(field) if klass.attribute_alias?(field) + from = from_clause.name || from_clause.value + + if klass.columns_hash.key?(field) && (!from || table_name_matches?(from)) + arel_attribute(field) + else + yield field + end + end + + def table_name_matches?(from) + /(?:\A|(?<!FROM)\s)(?:\b#{table.name}\b|#{connection.quote_table_name(table.name)})(?!\.)/i.match?(from.to_s) + end + def reverse_sql_order(order_query) if order_query.empty? return [arel_attribute(primary_key).desc] if primary_key @@ -1081,7 +1202,7 @@ module ActiveRecord o.reverse when String if does_not_support_reverse?(o) - raise IrreversibleOrderError, "Order #{o.inspect} can not be reversed automatically" + raise IrreversibleOrderError, "Order #{o.inspect} cannot be reversed automatically" end o.split(",").map! do |s| s.strip! @@ -1101,7 +1222,7 @@ module ActiveRecord # Uses SQL function with multiple arguments. (order.include?(",") && order.split(",").find { |section| section.count("(") != section.count(")") }) || # Uses "nulls first" like construction. - /nulls (first|last)\Z/i.match?(order) + /\bnulls\s+(?:first|last)\b/i.match?(order) end def build_order(arel) @@ -1132,9 +1253,9 @@ module ActiveRecord end order_args.flatten! - @klass.enforce_raw_sql_whitelist( + @klass.disallow_raw_sql!( order_args.flat_map { |a| a.is_a?(Hash) ? a.keys : a }, - whitelist: AttributeMethods::ClassMethods::COLUMN_NAME_ORDER_WHITELIST + permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER ) validate_order_args(order_args) @@ -1147,14 +1268,20 @@ module ActiveRecord order_args.map! do |arg| case arg when Symbol - arel_attribute(arg).asc + arg = arg.to_s + arel_column(arg) { + Arel.sql(connection.quote_table_name(arg)) + }.asc when Hash arg.map { |field, dir| case field when Arel::Nodes::SqlLiteral field.send(dir.downcase) else - arel_attribute(field).send(dir.downcase) + field = field.to_s + arel_column(field) { + Arel.sql(connection.quote_table_name(field)) + }.send(dir.downcase) end } else @@ -1187,8 +1314,9 @@ module ActiveRecord STRUCTURAL_OR_METHODS = Relation::VALUE_METHODS - [:extending, :where, :having, :unscope, :references] def structurally_incompatible_values_for_or(other) + values = other.values STRUCTURAL_OR_METHODS.reject do |method| - get_value(method) == other.get_value(method) + get_value(method) == values.fetch(method, DEFAULT_VALUES[method]) end end diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb index b092399657..efc4b447aa 100644 --- a/activerecord/lib/active_record/relation/spawn_methods.rb +++ b/activerecord/lib/active_record/relation/spawn_methods.rb @@ -8,9 +8,8 @@ module ActiveRecord module SpawnMethods # This is overridden by Associations::CollectionProxy def spawn #:nodoc: - clone + already_in_scope? ? klass.all : clone end - alias :all :spawn # Merges in the conditions from <tt>other</tt>, if <tt>other</tt> is an ActiveRecord::Relation. # Returns an array representing the intersection of the resulting records with <tt>other</tt>, if <tt>other</tt> is an array. diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index a502713e56..47728aac30 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -125,6 +125,10 @@ module ActiveRecord raise ArgumentError, "Invalid argument for .where.not(), got nil." when Arel::Nodes::In Arel::Nodes::NotIn.new(node.left, node.right) + when Arel::Nodes::IsNotDistinctFrom + Arel::Nodes::IsDistinctFrom.new(node.left, node.right) + when Arel::Nodes::IsDistinctFrom + Arel::Nodes::IsNotDistinctFrom.new(node.left, node.right) when Arel::Nodes::Equality Arel::Nodes::NotEqual.new(node.left, node.right) when String @@ -136,11 +140,7 @@ module ActiveRecord def except_predicates(columns) predicates.reject do |node| - case node - when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual - subrelation = (node.left.kind_of?(Arel::Attributes::Attribute) ? node.left : node.right) - columns.include?(subrelation.name.to_s) - end + Arel.fetch_attribute(node) { |attr| columns.include?(attr.name.to_s) } end end diff --git a/activerecord/lib/active_record/result.rb b/activerecord/lib/active_record/result.rb index e54e8086dd..da6d10b6ec 100644 --- a/activerecord/lib/active_record/result.rb +++ b/activerecord/lib/active_record/result.rb @@ -21,7 +21,7 @@ module ActiveRecord # ] # # # Get an array of hashes representing the result (column => value): - # result.to_hash + # result.to_a # # => [{"id" => 1, "title" => "title_1", "body" => "body_1"}, # {"id" => 2, "title" => "title_2", "body" => "body_2"}, # ... @@ -43,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 @@ -60,9 +65,12 @@ module ActiveRecord end end - # Returns an array of hashes representing each row record. def to_hash - hash_rows + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `ActiveRecord::Result#to_hash` has been renamed to `to_a`. + `to_hash` is deprecated and will be removed in Rails 6.1. + MSG + to_a end alias :map! :map @@ -78,6 +86,8 @@ module ActiveRecord hash_rows end + alias :to_a :to_ary + def [](idx) hash_rows[idx] end @@ -97,12 +107,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) - columns.one? ? result.map!(&:first) : result + rows.map do |(value)| + type.deserialize(value) + end + else + types = columns.map { |name| column_type(name, type_overrides) } + + rows.map do |values| + Array.new(values.size) { |i| types[i].deserialize(values[i]) } + end + end end def initialize_copy(other) @@ -125,7 +144,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 @@ -134,8 +155,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/sanitization.rb b/activerecord/lib/active_record/sanitization.rb index c6c268855e..750766714d 100644 --- a/activerecord/lib/active_record/sanitization.rb +++ b/activerecord/lib/active_record/sanitization.rb @@ -61,8 +61,8 @@ module ActiveRecord # # => "id ASC" def sanitize_sql_for_order(condition) if condition.is_a?(Array) && condition.first.to_s.include?("?") - enforce_raw_sql_whitelist([condition.first], - whitelist: AttributeMethods::ClassMethods::COLUMN_NAME_ORDER_WHITELIST + disallow_raw_sql!([condition.first], + permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER ) # Ensure we aren't dealing with a subclass of String that might @@ -134,43 +134,6 @@ module ActiveRecord 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. - # - # Given: - # - # class Person < ActiveRecord::Base - # composed_of :address, class_name: "Address", - # mapping: [%w(address_street street), %w(address_city city)] - # end - # - # Then: - # - # { address: Address.new("813 abc st.", "chicago") } - # # => { address_street: "813 abc st.", address_city: "chicago" } - def expand_hash_conditions_for_aggregates(attrs) # :doc: - expanded_attrs = {} - attrs.each do |attr, value| - if aggregation = reflect_on_aggregation(attr.to_sym) - mapping = aggregation.mapping - mapping.each do |field_attr, aggregate_attr| - 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 - value.send(aggregate_attr) - end - end - else - expanded_attrs[attr] = value - end - end - expanded_attrs - end - deprecate :expand_hash_conditions_for_aggregates - def replace_bind_variables(statement, values) raise_if_bind_arity_mismatch(statement, statement.count("?"), values.size) bound = values.dup @@ -202,10 +165,11 @@ module ActiveRecord def quote_bound_value(value, c = connection) if value.respond_to?(:map) && !value.acts_like?(:string) - if value.respond_to?(:empty?) && value.empty? + quoted = value.map { |v| c.quote(v) } + if quoted.empty? c.quote(nil) else - value.map { |v| c.quote(v) }.join(",") + quoted.join(",") end else c.quote(value) diff --git a/activerecord/lib/active_record/schema.rb b/activerecord/lib/active_record/schema.rb index 216359867c..76bf53387d 100644 --- a/activerecord/lib/active_record/schema.rb +++ b/activerecord/lib/active_record/schema.rb @@ -51,20 +51,11 @@ module ActiveRecord if info[:version].present? ActiveRecord::SchemaMigration.create_table - connection.assume_migrated_upto_version(info[:version], migrations_paths) + connection.assume_migrated_upto_version(info[:version]) end ActiveRecord::InternalMetadata.create_table ActiveRecord::InternalMetadata[:environment] = connection.migration_context.current_environment end - - private - # Returns the migrations paths. - # - # ActiveRecord::Schema.new.migrations_paths - # # => ["db/migrate"] # Rails migration path by default. - def migrations_paths - ActiveRecord::Migrator.migrations_paths - end end end diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index 9974c28445..2f7cc07221 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -47,6 +47,7 @@ module ActiveRecord end private + attr_accessor :table_name def initialize(connection, options = {}) @connection = connection @@ -71,11 +72,11 @@ module ActiveRecord # 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. @@ -110,6 +111,8 @@ HEADER def table(table, stream) columns = @connection.columns(table) begin + self.table_name = table + tbl = StringIO.new # first dump primary key column @@ -159,6 +162,8 @@ HEADER stream.puts "# Could not dump table #{table.inspect} because of following #{e.class}" stream.puts "# #{e.message}" stream.puts + ensure + self.table_name = nil end end diff --git a/activerecord/lib/active_record/schema_migration.rb b/activerecord/lib/active_record/schema_migration.rb index f2d8b038fa..74547de862 100644 --- a/activerecord/lib/active_record/schema_migration.rb +++ b/activerecord/lib/active_record/schema_migration.rb @@ -10,12 +10,16 @@ module ActiveRecord # to be executed the next time. class SchemaMigration < ActiveRecord::Base # :nodoc: class << self + def _internal? + true + end + def primary_key "version" end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.schema_migrations_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{schema_migrations_table_name}#{table_name_suffix}" end def table_exists? diff --git a/activerecord/lib/active_record/scoping.rb b/activerecord/lib/active_record/scoping.rb index 01ac56570a..35e9dcbffc 100644 --- a/activerecord/lib/active_record/scoping.rb +++ b/activerecord/lib/active_record/scoping.rb @@ -12,14 +12,6 @@ module ActiveRecord end module ClassMethods # :nodoc: - 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 - # Collects attributes from scopes that should be applied when creating # an AR instance for the particular class this is called on. def scope_attributes @@ -30,6 +22,14 @@ module ActiveRecord def scope_attributes? current_scope end + + 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: diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 8c612df27a..87bcfd5181 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -86,8 +86,8 @@ module ActiveRecord # # Should return a scope, you can call 'super' here etc. # end # end - def default_scope(scope = nil) # :doc: - scope = Proc.new if block_given? + def default_scope(scope = nil, &block) # :doc: + scope = block if block_given? if scope.is_a?(Relation) || !scope.respond_to?(:call) raise ArgumentError, @@ -100,7 +100,7 @@ module ActiveRecord self.default_scopes += [scope] end - def build_default_scope(base_rel = nil) + def build_default_scope(relation = relation()) return if abstract_class? if default_scope_override.nil? @@ -111,15 +111,14 @@ module ActiveRecord # The user has defined their own default scope method, so call that evaluate_default_scope do if scope = default_scope - (base_rel ||= relation).merge!(scope) + relation.merge!(scope) end end elsif default_scopes.any? - base_rel ||= relation evaluate_default_scope do - default_scopes.inject(base_rel) do |default_scope, scope| + default_scopes.inject(relation) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge!(base_rel.instance_exec(&scope)) + default_scope.instance_exec(&scope) || default_scope end end end diff --git a/activerecord/lib/active_record/scoping/named.rb b/activerecord/lib/active_record/scoping/named.rb index a784001587..cd9801b7a0 100644 --- a/activerecord/lib/active_record/scoping/named.rb +++ b/activerecord/lib/active_record/scoping/named.rb @@ -24,13 +24,21 @@ module ActiveRecord # You can define a scope that applies to all finders using # {default_scope}[rdoc-ref:Scoping::Default::ClassMethods#default_scope]. def all - current_scope = self.current_scope + scope = current_scope - if current_scope - if self == current_scope.klass - current_scope.clone + if scope + if scope._deprecated_scope_source + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Class level methods will no longer inherit scoping from `#{scope._deprecated_scope_source}` + in Rails 6.1. To continue using the scoped relation, pass it into the block directly. + To instead access the full set of models, as Rails 6.1 will, use `#{name}.unscoped`. + MSG + end + + if self == scope.klass + scope.clone else - relation.merge!(current_scope) + relation.merge!(scope) end else default_scoped @@ -38,9 +46,7 @@ module ActiveRecord end def scope_for_association(scope = relation) # :nodoc: - current_scope = self.current_scope - - if current_scope && current_scope.empty_scope? + if current_scope&.empty_scope? scope else default_scoped(scope) @@ -52,7 +58,7 @@ module ActiveRecord end def default_extensions # :nodoc: - if scope = current_scope || build_default_scope + if scope = scope_for_association || build_default_scope scope.extensions else [] @@ -181,20 +187,20 @@ module ActiveRecord extension = Module.new(&block) if block if body.respond_to?(:to_proc) - singleton_class.send(:define_method, name) do |*args| - scope = all - scope = scope._exec_scope(*args, &body) + singleton_class.define_method(name) do |*args| + scope = all._exec_scope(name, *args, &body) scope = scope.extending(extension) if extension scope end else - singleton_class.send(:define_method, name) do |*args| - scope = all - scope = scope.scoping { body.call(*args) || scope } + singleton_class.define_method(name) do |*args| + scope = body.call(*args) || all scope = scope.extending(extension) if extension scope end end + + generate_relation_method(name) end private diff --git a/activerecord/lib/active_record/statement_cache.rb b/activerecord/lib/active_record/statement_cache.rb index b41d3504fd..93bce15230 100644 --- a/activerecord/lib/active_record/statement_cache.rb +++ b/activerecord/lib/active_record/statement_cache.rb @@ -44,7 +44,7 @@ module ActiveRecord def initialize(values) @values = values @indexes = values.each_with_index.find_all { |thing, i| - Arel::Nodes::BindParam === thing + Substitute === thing }.map(&:last) end @@ -56,6 +56,28 @@ module ActiveRecord end end + class PartialQueryCollector + def initialize + @parts = [] + @binds = [] + end + + def <<(str) + @parts << str + self + end + + def add_bind(obj) + @binds << obj + @parts << Substitute.new + self + end + + def value + [@parts, @binds] + end + end + def self.query(sql) Query.new(sql) end @@ -64,6 +86,10 @@ module ActiveRecord PartialQuery.new(values) end + def self.partial_query_collector + PartialQueryCollector.new + end + class Params # :nodoc: def bind; Substitute.new; end end @@ -87,8 +113,8 @@ module ActiveRecord end end - def self.create(connection, block = Proc.new) - relation = block.call Params.new + def self.create(connection, callable = nil, &block) + relation = (callable || block).call Params.new query_builder, binds = connection.cacheable_query(self, relation.arel) bind_map = BindMap.new(binds) new(query_builder, bind_map, relation.klass) @@ -106,6 +132,8 @@ module ActiveRecord sql = query_builder.sql_for bind_values, connection klass.find_by_sql(sql, bind_values, preparable: true, &block) + rescue ::RangeError + nil end def self.unsupported_value?(value) diff --git a/activerecord/lib/active_record/store.rb b/activerecord/lib/active_record/store.rb index 8d628359c3..6fecb06897 100644 --- a/activerecord/lib/active_record/store.rb +++ b/activerecord/lib/active_record/store.rb @@ -11,6 +11,12 @@ module ActiveRecord # of the model. This is very helpful for easily exposing store keys to a form or elsewhere that's # already built around just accessing attributes on the model. # + # Every accessor comes with dirty tracking methods (+key_changed?+, +key_was+ and +key_change+) and + # methods to access the changes made during the last save (+saved_change_to_key?+, +saved_change_to_key+ and + # +key_before_last_save+). + # + # NOTE: There is no +key_will_change!+ method for accessors, use +store_will_change!+ instead. + # # Make sure that you declare the database column used for the serialized store as a text, so there's # plenty of room. # @@ -33,27 +39,38 @@ module ActiveRecord # 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', 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 # u.settings[:country] # => 'Denmark' # u.settings['country'] # => 'Denmark' # + # # Dirty tracking + # u.color = 'green' + # u.color_changed? # => true + # u.color_was # => 'black' + # u.color_change # => ['black', 'red'] + # # # Add additional accessors to an existing store through store_accessor # class SuperUser < User # store_accessor :settings, :privileges, :servants # 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 # @@ -86,10 +103,10 @@ module ActiveRecord module ClassMethods def store(store_attribute, options = {}) serialize store_attribute, IndifferentCoder.new(store_attribute, options[:coder]) - store_accessor(store_attribute, options[:accessors], prefix: options[:prefix]) 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, prefix: nil) + def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil) keys = keys.flatten accessor_prefix = @@ -101,16 +118,63 @@ module ActiveRecord 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("#{accessor_prefix}#{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("#{accessor_prefix}#{key}") do + define_method(accessor_key) do read_store_attribute(store_attribute, key) end + + define_method("#{accessor_key}_changed?") do + return false unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("#{accessor_key}_change") do + return unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_was") do + return unless attribute_changed?(store_attribute) + prev_store, _new_store = changes[store_attribute] + prev_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}?") do + return false unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_before_last_save") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, _new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) + end end end diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 521375954b..7285c15477 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_record/database_configurations" + module ActiveRecord module Tasks # :nodoc: class DatabaseAlreadyExists < StandardError; end # :nodoc: @@ -8,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 @@ -101,16 +103,21 @@ 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 @@ -122,7 +129,7 @@ module ActiveRecord $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 @@ -136,7 +143,7 @@ module ActiveRecord def for_each databases = Rails.application.config.load_database_yaml - database_configs = ActiveRecord::DatabaseConfigurations.configs_for(Rails.env, databases) + 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 @@ -175,19 +182,55 @@ module ActiveRecord } end + def truncate_tables(configuration) + ActiveRecord::Base.connected_to(database: { truncation: configuration }) do + table_names = ActiveRecord::Base.connection.tables + table_names -= [ + SchemaMigration.table_name, + InternalMetadata.table_name + ] + + ActiveRecord::Base.connection.truncate_tables(*table_names) + end + end + private :truncate_tables + + def truncate_all(environment = env) + ActiveRecord::Base.configurations.configs_for(env_name: environment).each do |db_config| + truncate_tables db_config.config + end + end + def migrate check_target_version 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 migrate_status + unless ActiveRecord::SchemaMigration.table_exists? + Kernel.abort "Schema migrations table does not exist yet." + end + + # output + puts "\ndatabase: #{ActiveRecord::Base.connection_config[:database]}\n\n" + puts "#{'Status'.center(8)} #{'Migration ID'.ljust(14)} Migration Name" + puts "-" * 50 + ActiveRecord::Base.connection.migration_context.migrations_status.each do |status, version, name| + puts "#{status.center(8)} #{version.ljust(14)} #{name}" + end + puts + end + 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']}`" @@ -198,8 +241,8 @@ module ActiveRecord ENV["VERSION"].to_i if ENV["VERSION"] && !ENV["VERSION"].empty? end - def charset_current(environment = env) - charset ActiveRecord::Base.configurations[environment] + 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) @@ -207,8 +250,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) @@ -248,6 +291,7 @@ module ActiveRecord 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) @@ -261,6 +305,8 @@ module ActiveRecord end ActiveRecord::InternalMetadata.create_table ActiveRecord::InternalMetadata[:environment] = environment + ensure + Migration.verbose = verbose_was end def schema_file(format = ActiveRecord::Base.schema_format) @@ -286,6 +332,16 @@ module ActiveRecord ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, filename) end + def cache_dump_filename(namespace) + filename = if namespace == "primary" + "schema_cache.yml" + else + "#{namespace}_schema_cache.yml" + end + + ENV["SCHEMA_CACHE"] || 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, spec_name, env| load_schema(configuration, format, file, env, spec_name) @@ -295,7 +351,7 @@ module ActiveRecord 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.}.dup + 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.root) Kernel.abort message end @@ -339,14 +395,15 @@ module ActiveRecord environments << "test" if environment == "development" environments.each do |env| - ActiveRecord::DatabaseConfigurations.configs_for(env) do |spec_name, configuration| - yield configuration, spec_name, 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 e697fa6def..1c1b29b5e1 100644 --- a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb @@ -68,9 +68,7 @@ module ActiveRecord private - def configuration - @configuration - end + attr_reader :configuration def configuration_without_database configuration.merge("database" => nil) @@ -106,7 +104,7 @@ module ActiveRecord end def run_cmd_error(cmd, args, action) - msg = "failed to execute: `#{cmd}`\n".dup + msg = +"failed to execute: `#{cmd}`\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 diff --git a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb index 647e066137..8acb11f75f 100644 --- a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb @@ -6,8 +6,8 @@ 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 + ON_ERROR_STOP_1 = "ON_ERROR_STOP=1" + SQL_COMMENT_BEGIN = "--" delegate :connection, :establish_connection, :clear_active_connections!, to: ActiveRecord::Base @@ -82,7 +82,7 @@ module ActiveRecord def structure_load(filename, extra_flags) set_psql_env - args = ["-v", ON_ERROR_STOP_1, "-q", "-f", filename] + args = ["-v", ON_ERROR_STOP_1, "-q", "-X", "-f", filename] args.concat(Array(extra_flags)) if extra_flags args << configuration["database"] run_cmd("psql", args, "loading") @@ -90,9 +90,7 @@ module ActiveRecord private - def configuration - @configuration - end + attr_reader :configuration def encoding configuration["encoding"] || DEFAULT_ENCODING @@ -117,7 +115,7 @@ module ActiveRecord end def run_cmd_error(cmd, args, action) - msg = "failed to execute:\n".dup + msg = +"failed to execute:\n" 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 diff --git a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb index dfe599c4dd..a82cea80ca 100644 --- a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb @@ -60,20 +60,14 @@ module ActiveRecord private - def configuration - @configuration - end - - def root - @root - end + 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 run_cmd_error(cmd, args) - msg = "failed to execute:\n".dup + msg = +"failed to execute:\n" 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 diff --git a/activerecord/lib/active_record/test_databases.rb b/activerecord/lib/active_record/test_databases.rb index 606a3b0fb5..999830ba79 100644 --- a/activerecord/lib/active_record/test_databases.rb +++ b/activerecord/lib/active_record/test_databases.rb @@ -5,32 +5,32 @@ require "active_support/testing/parallelization" module ActiveRecord module TestDatabases # :nodoc: ActiveSupport::Testing::Parallelization.after_fork_hook do |i| - create_and_migrate(i, spec_name: Rails.env) + create_and_load_schema(i, env_name: Rails.env) end - ActiveSupport::Testing::Parallelization.run_cleanup_hook do |i| - drop(i, spec_name: Rails.env) + ActiveSupport::Testing::Parallelization.run_cleanup_hook do + drop(env_name: Rails.env) end - def self.create_and_migrate(i, spec_name:) + def self.create_and_load_schema(i, env_name:) old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" - connection_spec = ActiveRecord::Base.configurations[spec_name] - - connection_spec["database"] += "-#{i}" - ActiveRecord::Tasks::DatabaseTasks.create(connection_spec) - ActiveRecord::Base.establish_connection(connection_spec) - ActiveRecord::Tasks::DatabaseTasks.migrate + 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(ActiveRecord::Base.configurations[Rails.env]) + ActiveRecord::Base.establish_connection(Rails.env.to_sym) ENV["VERBOSE"] = old end - def self.drop(i, spec_name:) + def self.drop(env_name:) old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" - connection_spec = ActiveRecord::Base.configurations[spec_name] - ActiveRecord::Tasks::DatabaseTasks.drop(connection_spec) + 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 diff --git a/activerecord/lib/active_record/test_fixtures.rb b/activerecord/lib/active_record/test_fixtures.rb new file mode 100644 index 0000000000..8c60d71669 --- /dev/null +++ b/activerecord/lib/active_record/test_fixtures.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module ActiveRecord + module TestFixtures + extend ActiveSupport::Concern + + def before_setup # :nodoc: + setup_fixtures + super + end + + def after_teardown # :nodoc: + super + teardown_fixtures + end + + included do + class_attribute :fixture_path, instance_writer: false + 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 + # Sets the model class for a fixture when the class name cannot be inferred from the fixture name. + # + # Examples: + # + # set_fixture_class some_fixture: SomeModel, + # 'namespaced/fixture' => Another::Model + # + # The keys must be the fixture names, that coincide with the short paths to the fixture files. + def set_fixture_class(class_names = {}) + self.fixture_class_names = fixture_class_names.merge(class_names.stringify_keys) + end + + def fixtures(*fixture_set_names) + if fixture_set_names.first == :all + raise StandardError, "No fixture path found. Please set `#{self}.fixture_path`." if fixture_path.blank? + fixture_set_names = Dir["#{fixture_path}/{**,*}/*.{yml}"].uniq + fixture_set_names.map! { |f| f[(fixture_path.to_s.size + 1)..-5] } + else + fixture_set_names = fixture_set_names.flatten.map(&:to_s) + end + + self.fixture_table_names |= fixture_set_names + setup_fixture_accessors(fixture_set_names) + end + + def setup_fixture_accessors(fixture_set_names = nil) + fixture_set_names = Array(fixture_set_names || fixture_table_names) + methods = Module.new do + fixture_set_names.each do |fs_name| + fs_name = fs_name.to_s + accessor_name = fs_name.tr("/", "_").to_sym + + 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] ||= {} + + instances = fixture_names.map do |f_name| + f_name = f_name.to_s if f_name.is_a?(Symbol) + @fixture_cache[fs_name].delete(f_name) if force_reload + + if @loaded_fixtures[fs_name][f_name] + @fixture_cache[fs_name][f_name] ||= @loaded_fixtures[fs_name][f_name].find + else + raise StandardError, "No fixture named '#{f_name}' found for fixture set '#{fs_name}'" + end + end + + return_single_record ? instances.first : instances + end + private accessor_name + end + end + include methods + end + + def uses_transaction(*methods) + @uses_transaction = [] unless defined?(@uses_transaction) + @uses_transaction.concat methods.map(&:to_s) + end + + def uses_transaction?(method) + @uses_transaction = [] unless defined?(@uses_transaction) + @uses_transaction.include?(method.to_s) + end + end + + def run_in_transaction? + use_transactional_tests && + !self.class.uses_transaction?(method_name) + end + + def setup_fixtures(config = ActiveRecord::Base) + if pre_loaded_fixtures && !use_transactional_tests + raise RuntimeError, "pre_loaded_fixtures requires use_transactional_tests" + end + + @fixture_cache = {} + @fixture_connections = [] + @@already_loaded_fixtures ||= {} + @connection_subscriber = nil + + # Load fixtures once and begin transaction. + if run_in_transaction? + if @@already_loaded_fixtures[self.class] + @loaded_fixtures = @@already_loaded_fixtures[self.class] + else + @loaded_fixtures = load_fixtures(config) + @@already_loaded_fixtures[self.class] = @loaded_fixtures + end + + # Begin transactions for connections already established + @fixture_connections = enlist_fixture_connections + @fixture_connections.each do |connection| + connection.begin_transaction joinable: false, _lazy: false + connection.pool.lock_thread = true if lock_threads + end + + # When connections are established in the future, begin a transaction too + @connection_subscriber = ActiveSupport::Notifications.subscribe("!connection.active_record") do |_, _, _, _, payload| + spec_name = payload[:spec_name] if payload.key?(:spec_name) + + if spec_name + begin + connection = ActiveRecord::Base.connection_handler.retrieve_connection(spec_name) + rescue ConnectionNotEstablished + connection = nil + end + + if connection && !@fixture_connections.include?(connection) + connection.begin_transaction joinable: false, _lazy: false + connection.pool.lock_thread = true if lock_threads + @fixture_connections << connection + end + end + end + + # Load fixtures for every test. + else + ActiveRecord::FixtureSet.reset_cache + @@already_loaded_fixtures[self.class] = nil + @loaded_fixtures = load_fixtures(config) + end + + # Instantiate fixtures for every test if requested. + instantiate_fixtures if use_instantiated_fixtures + end + + def teardown_fixtures + # Rollback changes if a transaction is active. + if run_in_transaction? + ActiveSupport::Notifications.unsubscribe(@connection_subscriber) if @connection_subscriber + @fixture_connections.each do |connection| + connection.rollback_transaction if connection.transaction_open? + connection.pool.lock_thread = false + end + @fixture_connections.clear + else + ActiveRecord::FixtureSet.reset_cache + end + + ActiveRecord::Base.clear_active_connections! + end + + def enlist_fixture_connections + setup_shared_connection_pool + + ActiveRecord::Base.connection_handler.connection_pool_list.map(&:connection) + end + + private + + # Shares the writing connection pool with connections on + # other handlers. + # + # In an application with a primary and replica the test fixtures + # need to share a connection pool so that the reading connection + # can see data in the open transaction on the writing connection. + def setup_shared_connection_pool + writing_handler = ActiveRecord::Base.connection_handler + + ActiveRecord::Base.connection_handlers.values.each do |handler| + if handler != writing_handler + handler.connection_pool_list.each do |pool| + name = pool.spec.name + writing_connection = writing_handler.retrieve_connection_pool(name) + handler.send(:owner_to_pool)[name] = writing_connection + end + end + end + end + + def load_fixtures(config) + fixtures = ActiveRecord::FixtureSet.create_fixtures(fixture_path, fixture_table_names, fixture_class_names, config) + Hash[fixtures.map { |f| [f.name, f] }] + end + + def instantiate_fixtures + if pre_loaded_fixtures + raise RuntimeError, "Load fixtures before instantiating them." if ActiveRecord::FixtureSet.all_loaded_fixtures.empty? + ActiveRecord::FixtureSet.instantiate_all_loaded_fixtures(self, load_instances?) + else + raise RuntimeError, "Load fixtures before instantiating them." if @loaded_fixtures.nil? + @loaded_fixtures.each_value do |fixture_set| + ActiveRecord::FixtureSet.instantiate_fixtures(self, fixture_set, load_instances?) + end + end + end + + def load_instances? + use_instantiated_fixtures != :no_instances + end + end +end diff --git a/activerecord/lib/active_record/timestamp.rb b/activerecord/lib/active_record/timestamp.rb index 54aa7aca2c..04a1c03474 100644 --- a/activerecord/lib/active_record/timestamp.rb +++ b/activerecord/lib/active_record/timestamp.rb @@ -53,6 +53,12 @@ module ActiveRecord end 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) } @@ -95,8 +101,8 @@ module ActiveRecord super end - def _update_record(*args, touch: true, **options) - if touch && should_record_timestamps? + def _update_record + if @_touch_record && should_record_timestamps? current_time = current_time_from_proper_timezone timestamp_attributes_for_update_in_model.each do |column| @@ -104,7 +110,13 @@ module ActiveRecord _write_attribute(column, current_time) end end - super(*args) + + super + end + + def create_or_update(touch: true, **) + @_touch_record = touch + super end def should_record_timestamps? @@ -127,11 +139,10 @@ module ActiveRecord self.class.send(:current_time_from_proper_timezone) end - def max_updated_column_timestamp(timestamp_names = timestamp_attributes_for_update_in_model) - timestamp_names - .map { |attr| self[attr] } + def max_updated_column_timestamp + timestamp_attributes_for_update_in_model + .map { |attr| self[attr]&.to_time } .compact - .map(&:to_time) .max end diff --git a/activerecord/lib/active_record/touch_later.rb b/activerecord/lib/active_record/touch_later.rb index f70b7c50a2..5dc88fb26c 100644 --- a/activerecord/lib/active_record/touch_later.rb +++ b/activerecord/lib/active_record/touch_later.rb @@ -2,7 +2,7 @@ module ActiveRecord # = Active Record Touch Later - module TouchLater + module TouchLater # :nodoc: extend ActiveSupport::Concern included do diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index 97cba5d1c7..a45d228298 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -234,6 +234,12 @@ module ActiveRecord set_callback(:commit, :after, *args, &block) end + # Shortcut for <tt>after_commit :hook, on: [ :create, :update ]</tt>. + def after_save_commit(*args, &block) + set_options_for_callbacks!(args, on: [ :create, :update ]) + set_callback(:commit, :after, *args, &block) + end + # Shortcut for <tt>after_commit :hook, on: :create</tt>. def after_create_commit(*args, &block) set_options_for_callbacks!(args, on: :create) @@ -306,9 +312,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: @@ -319,17 +323,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 @@ -340,11 +333,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 @@ -382,33 +377,33 @@ 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 - ensure - if @transaction_state && @transaction_state.committed? - clear_transaction_record_state - end 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. @@ -440,22 +435,16 @@ module ActiveRecord 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 @@ -491,7 +480,8 @@ module ActiveRecord def update_attributes_from_transaction_state(transaction_state) if transaction_state && transaction_state.finalized? - restore_transaction_record_state if transaction_state.rolledback? + 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 diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index c303186ef2..03d00006b7 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -48,12 +48,11 @@ module ActiveRecord private - def current_adapter_name - ActiveRecord::Base.connection.adapter_name.downcase.to_sym - end + def current_adapter_name + ActiveRecord::Base.connection.adapter_name.downcase.to_sym + end end - Helpers = ActiveModel::Type::Helpers BigInteger = ActiveModel::Type::BigInteger Binary = ActiveModel::Type::Binary Boolean = ActiveModel::Type::Boolean diff --git a/activerecord/lib/active_record/type/serialized.rb b/activerecord/lib/active_record/type/serialized.rb index e882784691..0a2f6cb9fb 100644 --- a/activerecord/lib/active_record/type/serialized.rb +++ b/activerecord/lib/active_record/type/serialized.rb @@ -51,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/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb index 5a1dbc8e53..2c3a2fb797 100644 --- a/activerecord/lib/active_record/validations/uniqueness.rb +++ b/activerecord/lib/active_record/validations/uniqueness.rb @@ -12,7 +12,7 @@ module ActiveRecord raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \ "Pass a symbol or an array of symbols instead: `scope: :user_id`" end - super({ case_sensitive: true }.merge!(options)) + super @klass = options[:class] end @@ -25,7 +25,7 @@ module ActiveRecord if finder_class.primary_key relation = relation.where.not(finder_class.primary_key => record.id_in_database) else - raise UnknownPrimaryKey.new(finder_class, "Can not validate uniqueness for persisted record without primary key.") + raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.") end end relation = scope_relation(record, relation) @@ -56,33 +56,21 @@ module ActiveRecord end def build_relation(klass, attribute, value) - 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) + relation = klass.unscoped + comparison = relation.bind_attribute(attribute, value) do |attr, bind| + return relation.none! if bind.unboundable? + + if !options.key?(:case_sensitive) || bind.nil? + klass.connection.default_uniqueness_comparison(attr, bind, klass) + elsif options[:case_sensitive] + klass.connection.case_sensitive_comparison(attr, bind) + else + # will use SQL LOWER function before comparison, unless it detects a case insensitive collation + klass.connection.case_insensitive_comparison(attr, bind) + end 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) + relation.where!(comparison) end def scope_relation(record, relation) diff --git a/activerecord/lib/arel.rb b/activerecord/lib/arel.rb new file mode 100644 index 0000000000..361cd915cc --- /dev/null +++ b/activerecord/lib/arel.rb @@ -0,0 +1,51 @@ +# 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/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 + + def self.arel_node?(value) + value.is_a?(Arel::Node) || value.is_a?(Arel::Attribute) || value.is_a?(Arel::Nodes::SqlLiteral) + end + + def self.fetch_attribute(value) + case value + when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual + yield value.left.is_a?(Arel::Attributes::Attribute) ? value.left : value.right + end + end + + ## Convenience Alias + Node = Arel::Nodes::Node +end diff --git a/activerecord/lib/arel/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..0533544993 --- /dev/null +++ b/activerecord/lib/arel/collectors/composite.rb @@ -0,0 +1,31 @@ +# 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 + + private + 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..c0e9fff399 --- /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 = +"" + 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..54e1e562c2 --- /dev/null +++ b/activerecord/lib/arel/collectors/sql_string.rb @@ -0,0 +1,20 @@ +# 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 + 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..4b894bc4b1 --- /dev/null +++ b/activerecord/lib/arel/collectors/substitute_binds.rb @@ -0,0 +1,28 @@ +# 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 + + private + attr_reader :quoter, :delegate + 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..fdba937d64 --- /dev/null +++ b/activerecord/lib/arel/delete_manager.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class DeleteManager < Arel::TreeManager + include TreeManager::StatementMethods + + def initialize + super + @ast = Nodes::DeleteStatement.new + @ctx = @ast + end + + def from(relation) + @ast.relation = relation + self + 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..83ec23e403 --- /dev/null +++ b/activerecord/lib/arel/factory_methods.rb @@ -0,0 +1,49 @@ +# 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 + + def coalesce(*exprs) + Nodes::NamedFunction.new "COALESCE", exprs + 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..cb31e3060b --- /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) + end + self + end + + def create_values(values) + Nodes::ValuesList.new([values]) + 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..f994754620 --- /dev/null +++ b/activerecord/lib/arel/nodes.rb @@ -0,0 +1,68 @@ +# 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_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/comment" + +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..bf516db35f --- /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::NodeExpression + 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..344e46479f --- /dev/null +++ b/activerecord/lib/arel/nodes/bind_param.rb @@ -0,0 +1,36 @@ +# 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 + + def infinite? + value.respond_to?(:infinite?) && value.infinite? + end + + def unboundable? + value.respond_to?(:unboundable?) && value.unboundable? + 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..1c4b727bf6 --- /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::NodeExpression + 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..6e911b717d --- /dev/null +++ b/activerecord/lib/arel/nodes/casted.rb @@ -0,0 +1,50 @@ +# 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 + + def infinite? + value.respond_to?(:infinite?) && value.infinite? + 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/comment.rb b/activerecord/lib/arel/nodes/comment.rb new file mode 100644 index 0000000000..237ff27e7e --- /dev/null +++ b/activerecord/lib/arel/nodes/comment.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Comment < Arel::Nodes::Node + attr_reader :values + + def initialize(values) + super() + @values = values + end + + def initialize_copy(other) + super + @values = @values.clone + end + + def hash + [@values].hash + end + + def eql?(other) + self.class == other.class && + self.values == other.values + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/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..a419975335 --- /dev/null +++ b/activerecord/lib/arel/nodes/delete_statement.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class DeleteStatement < Arel::Nodes::Node + attr_accessor :left, :right, :orders, :limit, :offset, :key + + alias :relation :left + alias :relation= :left= + alias :wheres :right + alias :wheres= :right= + + def initialize(relation = nil, wheres = []) + super() + @left = relation + @right = wheres + @orders = [] + @limit = nil + @offset = nil + @key = nil + end + + def initialize_copy(other) + super + @left = @left.clone if @left + @right = @right.clone if @right + end + + def hash + [self.class, @left, @right, @orders, @limit, @offset, @key].hash + end + + def eql?(other) + self.class == other.class && + self.left == other.left && + self.right == other.right && + self.orders == other.orders && + self.limit == other.limit && + self.offset == other.offset && + self.key == other.key + 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..551d56c2ff --- /dev/null +++ b/activerecord/lib/arel/nodes/equality.rb @@ -0,0 +1,18 @@ +# 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 + + %w{ + IsDistinctFrom + IsNotDistinctFrom + }.each do |name| + const_set name, Class.new(Equality) + 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..8086102bde --- /dev/null +++ b/activerecord/lib/arel/nodes/node.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + ### + # Abstract base class for all AST nodes + class Node + include Arel::FactoryMethods + include Enumerable + + ### + # 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..11b4f39ece --- /dev/null +++ b/activerecord/lib/arel/nodes/select_core.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class SelectCore < Arel::Nodes::Node + attr_accessor :projections, :wheres, :groups, :windows, :comment + attr_accessor :havings, :source, :set_quantifier, :optimizer_hints + + def initialize + super() + @source = JoinSource.new nil + + # https://ronsavage.github.io/SQL/sql-92.bnf.html#set%20quantifier + @set_quantifier = nil + @optimizer_hints = nil + @projections = [] + @wheres = [] + @groups = [] + @havings = [] + @windows = [] + @comment = nil + 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, @set_quantifier, @projections, @optimizer_hints, + @wheres, @groups, @havings, @windows, @comment + ].hash + end + + def eql?(other) + self.class == other.class && + self.source == other.source && + self.set_quantifier == other.set_quantifier && + self.optimizer_hints == other.optimizer_hints && + self.projections == other.projections && + self.wheres == other.wheres && + self.groups == other.groups && + self.havings == other.havings && + self.windows == other.windows && + self.comment == other.comment + 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..6d1ac36b0e --- /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 + OptimizerHints + Ordering + RollUp + }.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..cfaa19e392 --- /dev/null +++ b/activerecord/lib/arel/nodes/update_statement.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class UpdateStatement < Arel::Nodes::Node + attr_accessor :relation, :wheres, :values, :orders, :limit, :offset, :key + + def initialize + @relation = nil + @wheres = [] + @values = [] + @orders = [] + @limit = nil + @offset = nil + @key = nil + end + + def initialize_copy(other) + super + @wheres = @wheres.clone + @values = @values.clone + end + + def hash + [@relation, @wheres, @values, @orders, @limit, @offset, @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.offset == other.offset && + self.key == other.key + end + alias :== :eql? + 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..1a9d9ebf01 --- /dev/null +++ b/activerecord/lib/arel/nodes/values_list.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class ValuesList < Unary + alias :rows :expr + 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..7dafde4952 --- /dev/null +++ b/activerecord/lib/arel/predications.rb @@ -0,0 +1,257 @@ +# 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 is_not_distinct_from(other) + Nodes::IsNotDistinctFrom.new self, quoted_node(other) + end + + def is_distinct_from(other) + Nodes::IsDistinctFrom.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 unboundable?(other.begin) == 1 || unboundable?(other.end) == -1 + self.in([]) + elsif open_ended?(other.begin) + if other.end.nil? || open_ended?(other.end) + not_in([]) + elsif other.exclude_end? + lt(other.end) + else + lteq(other.end) + end + elsif other.end.nil? || open_ended?(other.end) + 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 unboundable?(other.begin) == 1 || unboundable?(other.end) == -1 + not_in([]) + elsif open_ended?(other.begin) + if other.end.nil? || open_ended?(other.end) + self.in([]) + elsif other.exclude_end? + gteq(other.end) + else + gt(other.end) + end + elsif other.end.nil? || open_ended?(other.end) + 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 infinity?(value) + value.respond_to?(:infinite?) && value.infinite? + end + + def unboundable?(value) + value.respond_to?(:unboundable?) && value.unboundable? + end + + def open_ended?(value) + infinity?(value) || unboundable?(value) + 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..ddc9e394dd --- /dev/null +++ b/activerecord/lib/arel/select_manager.rb @@ -0,0 +1,271 @@ +# 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 optimizer_hints(*hints) + unless hints.empty? + @ctx.optimizer_hints = Arel::Nodes::OptimizerHints.new(hints) + end + self + end + + def distinct(value = true) + if value + @ctx.set_quantifier = Arel::Nodes::Distinct.new + 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) + else + @ast.limit = nil + end + self + end + alias limit= take + + def join_sources + @ctx.source.right + end + + def source + @ctx.source + end + + def comment(*values) + @ctx.comment = Nodes::Comment.new(values) + self + 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..c40c68715a --- /dev/null +++ b/activerecord/lib/arel/table.rb @@ -0,0 +1,110 @@ +# 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 + + private + 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..0476399618 --- /dev/null +++ b/activerecord/lib/arel/tree_manager.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class TreeManager + include Arel::FactoryMethods + + module StatementMethods + def take(limit) + @ast.limit = Nodes::Limit.new(Nodes.build_quoted(limit)) if limit + self + end + + def offset(offset) + @ast.offset = Nodes::Offset.new(Nodes.build_quoted(offset)) if offset + self + end + + def order(*expr) + @ast.orders = expr + self + end + + def key=(key) + @ast.key = Nodes.build_quoted(key) + end + + def key + @ast.key + end + + def wheres=(exprs) + @ast.wheres = exprs + end + + def where(expr) + @ast.wheres << expr + self + end + end + + 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..a809dbb307 --- /dev/null +++ b/activerecord/lib/arel/update_manager.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + class UpdateManager < Arel::TreeManager + include TreeManager::StatementMethods + + def initialize + super + @ast = Nodes::UpdateStatement.new + @ctx = @ast + end + + ### + # UPDATE +table+ + def table(table) + @ast.relation = table + 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..d696edc507 --- /dev/null +++ b/activerecord/lib/arel/visitors/depth_first.rb @@ -0,0 +1,204 @@ +# 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_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_OptimizerHints :unary + alias :visit_Arel_Nodes_ValuesList :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_IsNotDistinctFrom :binary + alias :visit_Arel_Nodes_IsDistinctFrom :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_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_Class :terminal + alias :visit_Date :terminal + alias :visit_DateTime :terminal + alias :visit_FalseClass :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_Arel_Nodes_Comment(o) + visit o.values + 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..ecc386de07 --- /dev/null +++ b/activerecord/lib/arel/visitors/dot.rb @@ -0,0 +1,297 @@ +# 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_ValuesList(o) + visit_edge o, "rows" + 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_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_OptimizerHints :unary + alias :visit_Arel_Nodes_Preceding :unary + alias :visit_Arel_Nodes_Following :unary + alias :visit_Arel_Nodes_Rows :unary + 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_IsNotDistinctFrom :binary + alias :visit_Arel_Nodes_IsDistinctFrom :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_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_Arel_Nodes_Comment(o) + visit_edge(o, "values") + end + + 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..5cf958f5f0 --- /dev/null +++ b/activerecord/lib/arel/visitors/ibm_db.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class IBM_DB < Arel::Visitors::ToSql + private + def visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join + collector << "/* <OPTGUIDELINES>#{hints}</OPTGUIDELINES> */" + end + + def visit_Arel_Nodes_Limit(o, collector) + collector << "FETCH FIRST " + collector = visit o.expr, collector + collector << " ROWS ONLY" + end + + def is_distinct_from(o, collector) + collector << "DECODE(" + collector = visit [o.left, o.right, 0, 1], collector + collector << ")" + end + + def collect_optimizer_hints(o, collector) + collector + end + end + end +end diff --git a/activerecord/lib/arel/visitors/informix.rb b/activerecord/lib/arel/visitors/informix.rb new file mode 100644 index 0000000000..1a4ad1c8d8 --- /dev/null +++ b/activerecord/lib/arel/visitors/informix.rb @@ -0,0 +1,62 @@ +# 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 + 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_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join(", ") + collector << "/*+ #{hints} */" + end + + def visit_Arel_Nodes_Offset(o, collector) + collector << "SKIP " + visit o.expr, collector + end + + def visit_Arel_Nodes_Limit(o, collector) + collector << "FIRST " + visit o.expr, collector + 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..8475139870 --- /dev/null +++ b/activerecord/lib/arel/visitors/mssql.rb @@ -0,0 +1,157 @@ +# 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 + + def visit_Arel_Nodes_IsNotDistinctFrom(o, collector) + right = o.right + + if right.nil? + collector = visit o.left, collector + collector << " IS NULL" + else + collector << "EXISTS (VALUES (" + collector = visit o.left, collector + collector << ") INTERSECT VALUES (" + collector = visit right, collector + collector << "))" + end + end + + def visit_Arel_Nodes_IsDistinctFrom(o, collector) + if o.right.nil? + collector = visit o.left, collector + collector << " IS NOT NULL" + else + collector << "NOT " + visit_Arel_Nodes_IsNotDistinctFrom o, collector + end + 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 visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join(", ") + collector << "OPTION (#{hints})" + end + + def get_offset_limit_clause(o) + first_row = o.offset ? o.offset.expr.to_i + 1 : 1 + last_row = o.limit ? o.limit.expr.to_i - 1 + first_row : nil + 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 collect_optimizer_hints(o, collector) + collector + 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..dd77cfdf66 --- /dev/null +++ b/activerecord/lib/arel/visitors/mysql.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class MySQL < Arel::Visitors::ToSql + private + def visit_Arel_Nodes_Bin(o, collector) + collector << "BINARY " + visit o.expr, collector + end + + def visit_Arel_Nodes_UnqualifiedColumn(o, collector) + 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_Concat(o, collector) + collector << " CONCAT(" + visit o.left, collector + collector << ", " + visit o.right, collector + collector << ") " + collector + end + + def visit_Arel_Nodes_IsNotDistinctFrom(o, collector) + collector = visit o.left, collector + collector << " <=> " + visit o.right, collector + end + + def visit_Arel_Nodes_IsDistinctFrom(o, collector) + collector << "NOT " + visit_Arel_Nodes_IsNotDistinctFrom o, collector + end + + # In the simple case, MySQL allows us to place JOINs directly into the UPDATE + # query. However, this does not allow for LIMIT, OFFSET and ORDER. To support + # these, we must use a subquery. + def prepare_update_statement(o) + if o.offset || has_join_sources?(o) && has_limit_or_offset_or_orders?(o) + super + else + o + end + end + alias :prepare_delete_statement :prepare_update_statement + + # 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. + def build_subselect(key, o) + subselect = super + + # Materialize subquery by adding distinct + # to work with MySQL 5.7.6 which sets optimizer_switch='derived_merge=on' + unless has_limit_or_offset_or_orders?(subselect) + core = subselect.cores.last + core.set_quantifier = Arel::Nodes::Distinct.new + end + + Nodes::SelectStatement.new.tap do |stmt| + core = stmt.cores.last + core.froms = Nodes::Grouping.new(subselect).as("__active_record_temp") + core.projections = [Arel.sql(quote_column_name(key.name))] + end + 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..500974dff5 --- /dev/null +++ b/activerecord/lib/arel/visitors/oracle.rb @@ -0,0 +1,207 @@ +# 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_In(o, collector) + if Array === o.right && !o.right.empty? + o.right.delete_if { |value| unboundable?(value) } + end + + if Array === o.right && o.right.empty? + collector << "1=0" + else + first = true + o.right.each_slice(in_clause_length) do |sliced_o_right| + collector << " OR " unless first + first = false + + collector = visit o.left, collector + collector << " IN (" + visit(sliced_o_right, collector) + collector << ")" + end + end + collector + end + + def visit_Arel_Nodes_NotIn(o, collector) + if Array === o.right && !o.right.empty? + o.right.delete_if { |value| unboundable?(value) } + end + + if Array === o.right && o.right.empty? + collector << "1=1" + else + first = true + o.right.each_slice(in_clause_length) do |sliced_o_right| + collector << " AND " unless first + first = false + + collector = visit o.left, collector + collector << " NOT IN (" + visit(sliced_o_right, collector) + collector << ")" + end + end + 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 + + def is_distinct_from(o, collector) + collector << "DECODE(" + collector = visit [o.left, o.right, 0, 1], collector + collector << ")" + end + + def in_clause_length + 1000 + 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..8e0f07fca9 --- /dev/null +++ b/activerecord/lib/arel/visitors/oracle12.rb @@ -0,0 +1,115 @@ +# 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 + 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_In(o, collector) + if Array === o.right && !o.right.empty? + o.right.delete_if { |value| unboundable?(value) } + end + + if Array === o.right && o.right.empty? + collector << "1=0" + else + first = true + o.right.each_slice(in_clause_length) do |sliced_o_right| + collector << " OR " unless first + first = false + + collector = visit o.left, collector + collector << " IN (" + visit(sliced_o_right, collector) + collector << ")" + end + end + collector + end + + def visit_Arel_Nodes_NotIn(o, collector) + if Array === o.right && !o.right.empty? + o.right.delete_if { |value| unboundable?(value) } + end + + if Array === o.right && o.right.empty? + collector << "1=1" + else + first = true + o.right.each_slice(in_clause_length) do |sliced_o_right| + collector << " AND " unless first + first = false + + collector = visit o.left, collector + collector << " NOT IN (" + visit(sliced_o_right, collector) + collector << ")" + end + end + 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 + + def is_distinct_from(o, collector) + collector << "DECODE(" + collector = visit [o.left, o.right, 0, 1], collector + collector << ")" + end + + def in_clause_length + 1000 + 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..8296f1cdc1 --- /dev/null +++ b/activerecord/lib/arel/visitors/postgresql.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Visitors + class PostgreSQL < Arel::Visitors::ToSql + 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 " + grouping_parentheses o, collector + end + + def visit_Arel_Nodes_IsNotDistinctFrom(o, collector) + collector = visit o.left, collector + collector << " IS NOT DISTINCT FROM " + visit o.right, collector + end + + def visit_Arel_Nodes_IsDistinctFrom(o, collector) + collector = visit o.left, collector + collector << " IS DISTINCT FROM " + visit o.right, 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..af6f7e856a --- /dev/null +++ b/activerecord/lib/arel/visitors/sqlite.rb @@ -0,0 +1,39 @@ +# 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 + + def visit_Arel_Nodes_IsNotDistinctFrom(o, collector) + collector = visit o.left, collector + collector << " IS " + visit o.right, collector + end + + def visit_Arel_Nodes_IsDistinctFrom(o, collector) + collector = visit o.left, collector + collector << " IS NOT " + visit o.right, collector + 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..277d553e6c --- /dev/null +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -0,0 +1,860 @@ +# 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 + def initialize(connection) + super() + @connection = connection + end + + def compile(node, collector = Arel::Collectors::SQLString.new) + accept(node, collector).value + end + + private + + def visit_Arel_Nodes_DeleteStatement(o, collector) + o = prepare_delete_statement(o) + + if has_join_sources?(o) + collector << "DELETE " + visit o.relation.left, collector + collector << " FROM " + else + collector << "DELETE FROM " + end + collector = visit o.relation, collector + + collect_nodes_for o.wheres, collector, " WHERE ", " AND " + collect_nodes_for o.orders, collector, " ORDER BY " + maybe_visit o.limit, collector + end + + def visit_Arel_Nodes_UpdateStatement(o, collector) + o = prepare_update_statement(o) + + collector << "UPDATE " + collector = visit o.relation, collector + collect_nodes_for o.values, collector, " SET " + + collect_nodes_for o.wheres, collector, " WHERE ", " AND " + collect_nodes_for o.orders, collector, " ORDER BY " + maybe_visit o.limit, 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).to_s + end + collector << ", " unless k == row_len + end + collector << ")" + collector << ", " unless i == len + } + collector + end + + def visit_Arel_Nodes_SelectStatement(o, collector) + if o.with + collector = visit o.with, collector + collector << " " + 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 << ", " unless len == i + } + end + + visit_Arel_Nodes_SelectOptions(o, collector) + end + + def visit_Arel_Nodes_SelectOptions(o, collector) + collector = maybe_visit o.limit, collector + collector = maybe_visit o.offset, collector + maybe_visit o.lock, collector + end + + def visit_Arel_Nodes_SelectCore(o, collector) + collector << "SELECT" + + collector = collect_optimizer_hints(o, collector) + collector = maybe_visit o.set_quantifier, collector + + collect_nodes_for o.projections, collector, " " + + if o.source && !o.source.empty? + collector << " FROM " + collector = visit o.source, collector + end + + collect_nodes_for o.wheres, collector, " WHERE ", " AND " + collect_nodes_for o.groups, collector, " GROUP BY " + collect_nodes_for o.havings, collector, " HAVING ", " AND " + collect_nodes_for o.windows, collector, " WINDOW " + + maybe_visit o.comment, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_sql_comment(v) }.join(" ") + collector << "/*+ #{hints} */" + end + + def visit_Arel_Nodes_Comment(o, collector) + collector << o.values.map { |v| "/* #{sanitize_as_sql_comment(v)} */" }.join(" ") + end + + def collect_nodes_for(nodes, collector, spacer, connector = ", ") + unless nodes.empty? + collector << spacer + inject_join nodes, collector, connector + 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, ", " + end + + def visit_Arel_Nodes_WithRecursive(o, collector) + collector << "WITH RECURSIVE " + inject_join o.children, collector, ", " + end + + def visit_Arel_Nodes_Union(o, collector) + infix_value_with_paren(o, collector, " UNION ") + end + + def visit_Arel_Nodes_UnionAll(o, collector) + infix_value_with_paren(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 << "(" + + collect_nodes_for o.partitions, collector, "PARTITION BY " + + if o.orders.any? + collector << " " if o.partitions.any? + collector << "ORDER BY " + collector = inject_join o.orders, collector, ", " + end + + if o.framing + collector << " " 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 + + 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 << " " if o.left + collector = inject_join o.right, collector, " " + 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 << " " + 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 << " " + visit o.right, collector + end + + def visit_Arel_Nodes_InnerJoin(o, collector) + collector << "INNER JOIN " + collector = visit o.left, collector + if o.right + collector << " " + 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? + o.right.delete_if { |value| unboundable?(value) } + end + + 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? + o.right.delete_if { |value| unboundable?(value) } + end + + 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::Node, Arel::Attributes::Attribute + 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 + + return collector << "1=0" if unboundable?(right) + + collector = visit o.left, collector + + if right.nil? + collector << " IS NULL" + else + collector << " = " + visit right, collector + end + end + + def visit_Arel_Nodes_IsNotDistinctFrom(o, collector) + if o.right.nil? + collector = visit o.left, collector + collector << " IS NULL" + else + collector = is_distinct_from(o, collector) + collector << " = 0" + end + end + + def visit_Arel_Nodes_IsDistinctFrom(o, collector) + if o.right.nil? + collector = visit o.left, collector + collector << " IS NOT NULL" + else + collector = is_distinct_from(o, collector) + collector << " = 1" + end + end + + def visit_Arel_Nodes_NotEqual(o, collector) + right = o.right + + return collector << "1=1" if unboundable?(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_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 sanitize_as_sql_comment(value) + return value if Arel::Nodes::SqlLiteral === value + @connection.sanitize_as_sql_comment(value) + end + + def collect_optimizer_hints(o, collector) + maybe_visit o.optimizer_hints, collector + end + + def maybe_visit(thing, collector) + return collector unless thing + collector << " " + 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 unboundable?(value) + value.respond_to?(:unboundable?) && value.unboundable? + end + + def has_join_sources?(o) + o.relation.is_a?(Nodes::JoinSource) && !o.relation.right.empty? + end + + def has_limit_or_offset_or_orders?(o) + o.limit || o.offset || !o.orders.empty? + end + + # The default strategy for an UPDATE with joins is to use a subquery. This doesn't work + # on MySQL (even when aliasing the tables), but MySQL allows using JOIN directly in + # an UPDATE statement, so in the MySQL visitor we redefine this to do that. + def prepare_update_statement(o) + if o.key && (has_limit_or_offset_or_orders?(o) || has_join_sources?(o)) + stmt = o.clone + stmt.limit = nil + stmt.offset = nil + stmt.orders = [] + stmt.wheres = [Nodes::In.new(o.key, [build_subselect(o.key, o)])] + stmt.relation = o.relation.left if has_join_sources?(o) + stmt + else + o + end + end + alias :prepare_delete_statement :prepare_update_statement + + # 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.offset = o.offset + stmt.orders = o.orders + stmt + end + + def infix_value(o, collector, value) + collector = visit o.left, collector + collector << value + visit o.right, collector + end + + def infix_value_with_paren(o, collector, value, suppress_parens = false) + collector << "( " unless suppress_parens + collector = if o.left.class == o.class + infix_value_with_paren(o.left, collector, value, true) + else + visit o.left, collector + end + collector << value + collector = if o.right.class == o.class + infix_value_with_paren(o.right, collector, value, true) + else + visit o.right, collector + end + collector << " )" unless suppress_parens + 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 + + def is_distinct_from(o, collector) + collector << "CASE WHEN " + collector = visit o.left, collector + collector << " = " + collector = visit o.right, collector + collector << " OR (" + collector = visit o.left, collector + collector << " IS NULL AND " + collector = visit o.right, collector + collector << " IS NULL)" + collector << " THEN 0 ELSE 1 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/migration.rb b/activerecord/lib/rails/generators/active_record/migration.rb index 4ceb502c5d..cbb88d571d 100644 --- a/activerecord/lib/rails/generators/active_record/migration.rb +++ b/activerecord/lib/rails/generators/active_record/migration.rb @@ -25,11 +25,24 @@ module ActiveRecord def db_migrate_path if defined?(Rails.application) && Rails.application - Rails.application.config.paths["db/migrate"].to_ary.first + configured_migrate_path || default_migrate_path else "db/migrate" end end + + def default_migrate_path + Rails.application.config.paths["db/migrate"].to_ary.first + end + + def configured_migrate_path + return unless database = options[:database] + config = ActiveRecord::Base.configurations.configs_for( + env_name: Rails.env, + spec_name: database, + ) + config&.migrations_paths + end end end end 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 a07b00ef79..cb2c74f1ca 100644 --- a/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb +++ b/activerecord/lib/rails/generators/active_record/migration/migration_generator.rb @@ -8,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 :database, type: :string, aliases: %i(--db), desc: "The database for your migration. By default, the current environment's primary database is used." def create_migration_file set_local_assigns! diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt index 5f7201cfe1..562543f981 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt +++ b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt @@ -6,7 +6,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi t.string :password_digest<%= attribute.inject_options %> <% elsif attribute.token? -%> t.string :<%= attribute.name %><%= attribute.inject_options %> -<% else -%> +<% elsif !attribute.virtual? -%> t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %> <% end -%> <% end -%> diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt index 481c70201b..c07380bec9 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt +++ b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt @@ -7,7 +7,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- elsif attribute.token? -%> add_column :<%= table_name %>, :<%= attribute.name %>, :string<%= attribute.inject_options %> add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>, unique: true - <%- else -%> + <%- elsif !attribute.virtual? -%> add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> <%- if attribute.has_index? -%> add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %> @@ -21,7 +21,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- attributes.each do |attribute| -%> <%- if attribute.reference? -%> t.references :<%= attribute.name %><%= attribute.inject_options %> - <%- else -%> + <%- elsif !attribute.virtual? -%> <%= '# ' unless attribute.has_index? -%>t.index <%= attribute.index_name %><%= attribute.inject_index_options %> <%- end -%> <%- end -%> @@ -37,7 +37,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- if attribute.has_index? -%> remove_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %> <%- end -%> + <%- if !attribute.virtual? %> remove_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> + <%- end -%> <%- end -%> <%- end -%> <%- end -%> diff --git a/activerecord/lib/rails/generators/active_record/model/model_generator.rb b/activerecord/lib/rails/generators/active_record/model/model_generator.rb index 25e54f3ac8..c71bbdcab8 100644 --- a/activerecord/lib/rails/generators/active_record/model/model_generator.rb +++ b/activerecord/lib/rails/generators/active_record/model/model_generator.rb @@ -14,6 +14,7 @@ module ActiveRecord class_option :parent, type: :string, desc: "The parent class for the generated model" class_option :indexes, type: :boolean, default: true, desc: "Add indexes for references and belongs_to columns" class_option :primary_key_type, type: :string, desc: "The type for primary key" + class_option :database, type: :string, aliases: %i(--db), desc: "The database for your model's migration. By default, the current environment's primary database is used." # creates the migration file for the model. def create_migration_file diff --git a/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt index 55dc65c8ad..c1c03e2762 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt +++ b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt @@ -3,6 +3,15 @@ class <%= class_name %> < <%= parent_class_name.classify %> <% attributes.select(&:reference?).each do |attribute| -%> belongs_to :<%= attribute.name %><%= ', polymorphic: true' if attribute.polymorphic? %><%= ', required: true' if attribute.required? %> <% end -%> +<% attributes.select(&:rich_text?).each do |attribute| -%> + has_rich_text :<%= attribute.name %> +<% end -%> +<% attributes.select(&:attachment?).each do |attribute| -%> + has_one_attached :<%= attribute.name %> +<% end -%> +<% attributes.select(&:attachments?).each do |attribute| -%> + has_many_attached :<%= attribute.name %> +<% end -%> <% attributes.select(&:token?).each do |attribute| -%> has_secure_token<% if attribute.name != "token" %> :<%= attribute.name %><% end %> <% end -%> diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 4c41a68407..ba04859bf0 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/connection_helper" require "models/book" require "models/post" require "models/author" @@ -10,6 +11,7 @@ module ActiveRecord class AdapterTest < ActiveRecord::TestCase def setup @connection = ActiveRecord::Base.connection + @connection.materialize_transactions end ## @@ -84,7 +86,7 @@ module ActiveRecord 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 @@ -107,6 +109,11 @@ module ActiveRecord end end + def test_exec_query_returns_an_empty_result + result = @connection.exec_query "INSERT INTO subscribers(nick) VALUES('me')" + assert_instance_of(ActiveRecord::Result, result) + end + if current_adapter?(:Mysql2Adapter) def test_charset assert_not_nil @connection.charset @@ -125,19 +132,17 @@ module ActiveRecord end def test_not_specifying_database_name_for_cross_database_selects - begin - assert_nothing_raised do - ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"].except(:database)) - - config = ARTest.connection_config - ActiveRecord::Base.connection.execute( - "SELECT #{config['arunit']['database']}.pirates.*, #{config['arunit2']['database']}.courses.* " \ - "FROM #{config['arunit']['database']}.pirates, #{config['arunit2']['database']}.courses" - ) - end - ensure - ActiveRecord::Base.establish_connection :arunit + assert_nothing_raised do + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"].except(:database)) + + config = ARTest.connection_config + ActiveRecord::Base.connection.execute( + "SELECT #{config['arunit']['database']}.pirates.*, #{config['arunit2']['database']}.courses.* " \ + "FROM #{config['arunit']['database']}.pirates, #{config['arunit2']['database']}.courses" + ) end + ensure + ActiveRecord::Base.establish_connection :arunit end end @@ -158,6 +163,65 @@ module ActiveRecord end end + def test_preventing_writes_predicate + assert_not_predicate @connection, :preventing_writes? + + @connection.while_preventing_writes do + assert_predicate @connection, :preventing_writes? + end + + assert_not_predicate @connection, :preventing_writes? + end + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + assert_no_queries do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.transaction do + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) + end + end + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") + + assert_no_queries do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.transaction do + @connection.update("UPDATE subscribers SET nick = '9989' WHERE nick = '138853948594'") + end + end + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") + + assert_no_queries do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.transaction do + @connection.delete("DELETE FROM subscribers WHERE nick = '138853948594'") + end + end + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") + + @connection.while_preventing_writes do + result = @connection.select_all("SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") + assert_equal 1, result.length + 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 @@ -225,7 +289,7 @@ module ActiveRecord 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 + assert_equal expected.to_a, result.to_a end def test_insert_update_delete_with_legacy_binds @@ -284,22 +348,48 @@ module ActiveRecord assert_equal "special_db_type", @connection.type_to_sql(:special_db_type) end - unless current_adapter?(:PostgreSQLAdapter) - def test_log_invalid_encoding - error = assert_raises RuntimeError do - @connection.send :log, "SELECT 'ы' FROM DUAL" do - raise "ы".dup.force_encoding(Encoding::ASCII_8BIT) - end - end + def test_supports_foreign_keys_in_create_is_deprecated + assert_deprecated { @connection.supports_foreign_keys_in_create? } + end - assert_equal "ы", error.message - 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 @@ -318,7 +408,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 @@ -326,6 +416,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 @@ -338,14 +438,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 @@ -353,19 +452,21 @@ module ActiveRecord class AdapterTestWithoutTransaction < ActiveRecord::TestCase self.use_transactional_tests = false - class Klass < ActiveRecord::Base - end + fixtures :posts, :authors, :author_addresses def setup - Klass.establish_connection :arunit - @connection = Klass.connection - end - - teardown do - Klass.remove_connection + @connection = ActiveRecord::Base.connection end unless in_memory_db? + test "reconnect after a disconnect" do + assert_predicate @connection, :active? + @connection.disconnect! + assert_not_predicate @connection, :active? + @connection.reconnect! + assert_predicate @connection, :active? + end + test "transaction state is reset after a reconnect" do @connection.begin_transaction assert_predicate @connection, :transaction_open? @@ -378,9 +479,59 @@ module ActiveRecord assert_predicate @connection, :transaction_open? @connection.disconnect! assert_not_predicate @connection, :transaction_open? + ensure + @connection.reconnect! end end + def test_truncate + assert_operator Post.count, :>, 0 + + @connection.truncate("posts") + + assert_equal 0, Post.count + end + + def test_truncate_with_query_cache + @connection.enable_query_cache! + + assert_operator Post.count, :>, 0 + + @connection.truncate("posts") + + assert_equal 0, Post.count + ensure + @connection.disable_query_cache! + end + + def test_truncate_tables + assert_operator Post.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 + + @connection.truncate_tables("author_addresses", "authors", "posts") + + assert_equal 0, Post.count + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + end + + def test_truncate_tables_with_query_cache + @connection.enable_query_cache! + + assert_operator Post.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 + + @connection.truncate_tables("author_addresses", "authors", "posts") + + assert_equal 0, Post.count + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + ensure + @connection.disable_query_cache! + end + # test resetting sequences in odd tables in PostgreSQL if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!) require "models/movie" @@ -402,3 +553,27 @@ module ActiveRecord 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 +end diff --git a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb index 9ae2c42368..88c2ac5d0a 100644 --- a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb @@ -7,6 +7,7 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase include ConnectionHelper def setup + ActiveRecord::Base.connection.send(:default_row_format) ActiveRecord::Base.connection.singleton_class.class_eval do alias_method :execute_without_stub, :execute def execute(sql, name = nil) sql end @@ -68,18 +69,18 @@ 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`))" + expected = /\ACREATE 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 + assert_match expected, actual end - expected = "CREATE TABLE `people` ( INDEX `index_people_on_last_name` USING btree (`last_name`(10)))" + expected = /\ACREATE 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 - assert_equal expected, actual + assert_match expected, actual end def test_index_in_bulk_change @@ -106,7 +107,13 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase end def test_create_mysql_database_with_encoding - assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8`", create_database(:matt) + if row_format_dynamic_by_default? + assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8mb4`", create_database(:matt) + else + error = assert_raises(RuntimeError) { create_database(:matt) } + expected = "Configure a supported :charset and ensure innodb_large_prefix is enabled to support indexes on varchar(255) string columns." + assert_equal expected, error.message + end assert_equal "CREATE DATABASE `aimonetti` DEFAULT CHARACTER SET `latin1`", create_database(:aimonetti, charset: "latin1") assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT COLLATE `utf8mb4_bin`", create_database(:matt_aimonetti, collation: "utf8mb4_bin") end @@ -130,42 +137,42 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase def test_add_timestamps with_real_execute do - begin - ActiveRecord::Base.connection.create_table :delete_me - ActiveRecord::Base.connection.add_timestamps :delete_me, null: true - assert column_present?("delete_me", "updated_at", "datetime") - assert column_present?("delete_me", "created_at", "datetime") - ensure - ActiveRecord::Base.connection.drop_table :delete_me rescue nil - end + ActiveRecord::Base.connection.create_table :delete_me + ActiveRecord::Base.connection.add_timestamps :delete_me, null: true + assert column_exists?("delete_me", "updated_at", "datetime") + assert column_exists?("delete_me", "created_at", "datetime") + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil end end def test_remove_timestamps with_real_execute do - begin - ActiveRecord::Base.connection.create_table :delete_me do |t| - 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") - ensure - ActiveRecord::Base.connection.drop_table :delete_me rescue nil + ActiveRecord::Base.connection.create_table :delete_me do |t| + t.timestamps null: true end + ActiveRecord::Base.connection.remove_timestamps :delete_me, null: true + assert_not column_exists?("delete_me", "updated_at", "datetime") + assert_not column_exists?("delete_me", "created_at", "datetime") + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil end 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 = /\ACREATE TEMPORARY TABLE `temp` \( INDEX `index_temp_on_zip` \(`zip`\)\)(?: ROW_FORMAT=DYNAMIC)? 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`)) 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_match expected, actual end - - assert_equal expected, actual end private @@ -187,9 +194,4 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase def method_missing(method_symbol, *arguments) ActiveRecord::Base.connection.send(method_symbol, *arguments) end - - def column_present?(table_name, column_name, type) - results = ActiveRecord::Base.connection.select_all("SHOW FIELDS FROM #{table_name} LIKE '#{column_name}'") - results.first && results.first["Type"] == type - end end diff --git a/activerecord/test/cases/adapters/mysql2/annotate_test.rb b/activerecord/test/cases/adapters/mysql2/annotate_test.rb new file mode 100644 index 0000000000..b512540073 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/annotate_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +class Mysql2AnnotateTest < ActiveRecord::Mysql2TestCase + fixtures :posts + + def test_annotate_wraps_content_in_an_inline_comment + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/}) do + posts = Post.select(:id).annotate("foo") + assert posts.first + end + end + + def test_annotate_is_sanitized + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/}) do + posts = Post.select(:id).annotate("*/foo/*") + assert posts.first + end + + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/}) do + posts = Post.select(:id).annotate("**//foo//**") + assert posts.first + end + + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* foo \*/ /\* bar \*/}) do + posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") + assert posts.first + end + + assert_sql(%r{\ASELECT `posts`\.`id` FROM `posts` /\* \+ MAX_EXECUTION_TIME\(1\) \*/}) do + posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") + assert posts.first + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb index aa870349be..c32475c683 100644 --- a/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb +++ b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb @@ -9,8 +9,8 @@ class Mysql2CaseSensitivityTest < ActiveRecord::Mysql2TestCase repair_validations(CollationTest) def test_columns_include_collation_different_from_table - assert_equal "utf8_bin", CollationTest.columns_hash["string_cs_column"].collation - assert_equal "utf8_general_ci", CollationTest.columns_hash["string_ci_column"].collation + assert_equal "utf8mb4_bin", CollationTest.columns_hash["string_cs_column"].collation + assert_equal "utf8mb4_general_ci", CollationTest.columns_hash["string_ci_column"].collation end def test_case_sensitive diff --git a/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb index d0c57de65d..0bdbefdfb9 100644 --- a/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb +++ b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb @@ -32,20 +32,20 @@ class Mysql2CharsetCollationTest < ActiveRecord::Mysql2TestCase end test "add column with charset and collation" do - @connection.add_column :charset_collations, :title, :string, charset: "utf8", collation: "utf8_bin" + @connection.add_column :charset_collations, :title, :string, charset: "utf8mb4", collation: "utf8mb4_bin" column = @connection.columns(:charset_collations).find { |c| c.name == "title" } assert_equal :string, column.type - assert_equal "utf8_bin", column.collation + assert_equal "utf8mb4_bin", column.collation end test "change column with charset and collation" do - @connection.add_column :charset_collations, :description, :string, charset: "utf8", collation: "utf8_unicode_ci" - @connection.change_column :charset_collations, :description, :text, charset: "utf8", collation: "utf8_general_ci" + @connection.add_column :charset_collations, :description, :string, charset: "utf8mb4", collation: "utf8mb4_unicode_ci" + @connection.change_column :charset_collations, :description, :text, charset: "utf8mb4", collation: "utf8mb4_general_ci" column = @connection.columns(:charset_collations).find { |c| c.name == "description" } assert_equal :text, column.type - assert_equal "utf8_general_ci", column.collation + assert_equal "utf8mb4_general_ci", column.collation end test "schema dump includes collation" do diff --git a/activerecord/test/cases/adapters/mysql2/connection_test.rb b/activerecord/test/cases/adapters/mysql2/connection_test.rb index 726f58d58e..9c6566106a 100644 --- a/activerecord/test/cases/adapters/mysql2/connection_test.rb +++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb @@ -28,17 +28,6 @@ class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase end end - def test_truncate - rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments") - count = rows.first.values.first - assert_operator count, :>, 0 - - ActiveRecord::Base.connection.truncate("comments") - rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments") - count = rows.first.values.first - assert_equal 0, count - end - def test_no_automatic_reconnection_after_timeout assert_predicate @connection, :active? @connection.update("set @@wait_timeout=1") @@ -104,8 +93,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 @@ -170,6 +159,8 @@ 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 diff --git a/activerecord/test/cases/adapters/mysql2/count_deleted_rows_with_lock_test.rb b/activerecord/test/cases/adapters/mysql2/count_deleted_rows_with_lock_test.rb new file mode 100644 index 0000000000..4d361e405c --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/count_deleted_rows_with_lock_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" +require "models/author" +require "models/bulb" + +module ActiveRecord + class CountDeletedRowsWithLockTest < ActiveRecord::Mysql2TestCase + test "delete and create in different threads synchronize correctly" do + Bulb.unscoped.delete_all + Bulb.create!(name: "Jimmy", color: "blue") + + delete_thread = Thread.new do + Bulb.unscoped.delete_all + end + + create_thread = Thread.new do + Author.create!(name: "Tommy") + end + + delete_thread.join + create_thread.join + + assert_equal 1, delete_thread.value + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb index fa54f39992..cbe55f1d53 100644 --- a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb +++ b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb @@ -45,10 +45,8 @@ 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 - ensure - @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) + @connection.stub(:full_version, full_version_string) do + yield + 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 0719baaa23..6ade2eec24 100644 --- a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb +++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb @@ -56,7 +56,7 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase @conn.columns_for_distinct("posts.id", [order]) end - def test_errors_for_bigint_fks_on_integer_pk_table + def test_errors_for_bigint_fks_on_integer_pk_table_in_alter_table # table old_cars has primary key of integer error = assert_raises(ActiveRecord::MismatchedForeignKey) do @@ -64,9 +64,152 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase @conn.add_foreign_key :engines, :old_cars end - assert_match "Column `old_car_id` on table `engines` has a type of `bigint(20)`", error.message + assert_includes error.message, <<~MSG.squish + Column `old_car_id` on table `engines` does not match column `id` on `old_cars`, + which has type `int(11)`. To resolve this issue, change the type of the `old_car_id` + column on `engines` to be :integer. (For example `t.integer :old_car_id`). + MSG assert_not_nil error.cause - @conn.exec_query("ALTER TABLE engines DROP COLUMN old_car_id") + ensure + @conn.execute("ALTER TABLE engines DROP COLUMN old_car_id") rescue nil + end + + def test_errors_for_bigint_fks_on_integer_pk_table_in_create_table + # table old_cars has primary key of integer + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.execute(<<~SQL) + CREATE TABLE activerecord_unittest.foos ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + old_car_id bigint, + INDEX index_foos_on_old_car_id (old_car_id), + CONSTRAINT fk_rails_ff771f3c96 FOREIGN KEY (old_car_id) REFERENCES old_cars (id) + ) + SQL + end + + assert_includes error.message, <<~MSG.squish + Column `old_car_id` on table `foos` does not match column `id` on `old_cars`, + which has type `int(11)`. To resolve this issue, change the type of the `old_car_id` + column on `foos` to be :integer. (For example `t.integer :old_car_id`). + MSG + assert_not_nil error.cause + ensure + @conn.drop_table :foos, if_exists: true + end + + def test_errors_for_integer_fks_on_bigint_pk_table_in_create_table + # table old_cars has primary key of bigint + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.execute(<<~SQL) + CREATE TABLE activerecord_unittest.foos ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + car_id int, + INDEX index_foos_on_car_id (car_id), + CONSTRAINT fk_rails_ff771f3c96 FOREIGN KEY (car_id) REFERENCES cars (id) + ) + SQL + end + + assert_includes error.message, <<~MSG.squish + Column `car_id` on table `foos` does not match column `id` on `cars`, + which has type `bigint(20)`. To resolve this issue, change the type of the `car_id` + column on `foos` to be :bigint. (For example `t.bigint :car_id`). + MSG + assert_not_nil error.cause + ensure + @conn.drop_table :foos, if_exists: true + end + + def test_errors_for_bigint_fks_on_string_pk_table_in_create_table + # table old_cars has primary key of string + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.execute(<<~SQL) + CREATE TABLE activerecord_unittest.foos ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + subscriber_id bigint, + INDEX index_foos_on_subscriber_id (subscriber_id), + CONSTRAINT fk_rails_ff771f3c96 FOREIGN KEY (subscriber_id) REFERENCES subscribers (nick) + ) + SQL + end + + assert_includes error.message, <<~MSG.squish + Column `subscriber_id` on table `foos` does not match column `nick` on `subscribers`, + which has type `varchar(255)`. To resolve this issue, change the type of the `subscriber_id` + column on `foos` to be :string. (For example `t.string :subscriber_id`). + MSG + assert_not_nil error.cause + ensure + @conn.drop_table :foos, if_exists: true + end + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + @conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.update("UPDATE `engines` SET `engines`.`car_id` = '9989' WHERE `engines`.`car_id` = '138853948594'") + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("DELETE FROM `engines` where `engines`.`car_id` = '138853948594'") + end + end + end + + def test_errors_when_a_replace_query_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("REPLACE INTO `engines` SET `engines`.`car_id` = '249823948'") + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute("SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594'").entries.count + end + end + + def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes + @conn.while_preventing_writes do + assert_equal 2, @conn.execute("SHOW FULL FIELDS FROM `engines`").entries.count + end + end + + def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes + @conn.while_preventing_writes do + assert_nil @conn.execute("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci") + end + end + + def test_doesnt_error_when_a_read_query_with_leading_chars_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute("(\n( SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594' ) )").entries.count + end end private diff --git a/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb new file mode 100644 index 0000000000..628802b216 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +if supports_optimizer_hints? + class Mysql2OptimzerHintsTest < ActiveRecord::Mysql2TestCase + fixtures :posts + + def test_optimizer_hints + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |" + end + end + + def test_optimizer_hints_with_count_subquery + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)") + posts = posts.select(:id).where(author_id: [0, 1]).limit(5) + assert_equal 5, posts.count + end + end + + def test_optimizer_hints_is_sanitized + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |" + end + + assert_sql(%r{\ASELECT /\*\+ `posts`\.\*, \*/}) do + posts = Post.optimizer_hints("**// `posts`.*, //**") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_equal({ "id" => 1 }, posts.first.as_json) + end + end + + def test_optimizer_hints_with_unscope + assert_sql(%r{\ASELECT `posts`\.`id`}) do + posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */") + posts = posts.select(:id).where(author_id: [0, 1]) + posts.unscope(:optimizer_hints).load + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/schema_test.rb b/activerecord/test/cases/adapters/mysql2/schema_test.rb index b587e756cf..b8f51acba0 100644 --- a/activerecord/test/cases/adapters/mysql2/schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb @@ -41,7 +41,7 @@ module ActiveRecord column_24 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_24" } column_25 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_25" } - # Mysql floats are precision 0..24, Mysql doubles are precision 25..53 + # MySQL floats are precision 0..24, MySQL doubles are precision 25..53 assert_equal 24, column_no_limit.limit assert_equal 24, column_short.limit assert_equal 53, column_long.limit @@ -67,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 7b6dce71e9..626ef59570 100644 --- a/activerecord/test/cases/adapters/mysql2/sp_test.rb +++ b/activerecord/test/cases/adapters/mysql2/sp_test.rb @@ -9,7 +9,7 @@ class Mysql2StoredProcedureTest < ActiveRecord::Mysql2TestCase def setup @connection = ActiveRecord::Base.connection - unless ActiveRecord::Base.connection.version >= "5.6.0" + unless ActiveRecord::Base.connection.database_version >= "5.6.0" skip("no stored procedure support") end end diff --git a/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb index ffde8ed4d8..8494acee3b 100644 --- a/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb +++ b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb @@ -18,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 @@ -55,7 +56,8 @@ 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|UCASE)\(`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+"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 308ad1d854..62efaf3bfe 100644 --- a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -4,6 +4,8 @@ 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 @@ -27,7 +29,7 @@ class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase def test_add_index # add_index calls index_name_exists? which can't work since execute is stubbed - ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send(:define_method, :index_name_exists?) { |*| false } + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.define_method(:index_name_exists?) { |*| false } expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" ("last_name") WHERE state = 'active') assert_equal expected, add_index(:people, :last_name, unique: true, where: "state = 'active'") @@ -72,12 +74,12 @@ class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase add_index(:people, :last_name, algorithm: :copy) end - ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send :remove_method, :index_name_exists? + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.remove_method :index_name_exists? end def test_remove_index # remove_index calls index_name_for_remove which can't work since execute is stubbed - ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send(:define_method, :index_name_for_remove) do |*| + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.define_method(:index_name_for_remove) do |*| "index_people_on_last_name" end @@ -88,7 +90,7 @@ class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase add_index(:people, :last_name, algorithm: :copy) end - ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send :remove_method, :index_name_for_remove + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.remove_method :index_name_for_remove end def test_remove_index_when_name_is_specified diff --git a/activerecord/test/cases/adapters/postgresql/annotate_test.rb b/activerecord/test/cases/adapters/postgresql/annotate_test.rb new file mode 100644 index 0000000000..42a2861511 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/annotate_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +class PostgresqlAnnotateTest < ActiveRecord::PostgreSQLTestCase + fixtures :posts + + def test_annotate_wraps_content_in_an_inline_comment + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("foo") + assert posts.first + end + end + + def test_annotate_is_sanitized + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("*/foo/*") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("**//foo//**") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/ /\* bar \*/}) do + posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* \+ MAX_EXECUTION_TIME\(1\) \*/}) do + posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") + assert posts.first + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/array_test.rb b/activerecord/test/cases/adapters/postgresql/array_test.rb index 58fa7532a2..2e7a4b498f 100644 --- a/activerecord/test/cases/adapters/postgresql/array_test.rb +++ b/activerecord/test/cases/adapters/postgresql/array_test.rb @@ -17,7 +17,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase enable_extension!("hstore", @connection) @connection.transaction do - @connection.create_table("pg_arrays") do |t| + @connection.create_table "pg_arrays", force: true do |t| t.string "tags", array: true, limit: 255 t.integer "ratings", array: true t.datetime :datetimes, array: true @@ -112,6 +112,18 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase assert_predicate column, :array? end + def test_change_column_from_non_array_to_array + @connection.add_column :pg_arrays, :snippets, :string + @connection.change_column :pg_arrays, :snippets, :text, array: true, default: [], using: "string_to_array(\"snippets\", ',')" + + PgArray.reset_column_information + column = PgArray.columns_hash["snippets"] + + assert_equal :text, column.type + assert_equal [], PgArray.column_defaults["snippets"] + assert_predicate column, :array? + end + def test_change_column_cant_make_non_array_column_to_array @connection.add_column :pg_arrays, :a_string, :string assert_raises ActiveRecord::StatementInvalid do @@ -226,14 +238,6 @@ 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)) @@ -353,7 +357,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/bytea_test.rb b/activerecord/test/cases/adapters/postgresql/bytea_test.rb index 64bb6906cd..531e6b2328 100644 --- a/activerecord/test/cases/adapters/postgresql/bytea_test.rb +++ b/activerecord/test/cases/adapters/postgresql/bytea_test.rb @@ -35,7 +35,7 @@ class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase def test_binary_columns_are_limitless_the_upper_limit_is_one_GB assert_equal "bytea", @connection.type_to_sql(:binary, limit: 100_000) - assert_raise ActiveRecord::ActiveRecordError do + assert_raise ArgumentError do @connection.type_to_sql(:binary, limit: 4294967295) end end @@ -49,7 +49,7 @@ class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase end def test_type_cast_binary_value - data = "\u001F\x8B".dup.force_encoding("BINARY") + data = (+"\u001F\x8B").force_encoding("BINARY") assert_equal(data, @type.deserialize(data)) end diff --git a/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb index 305e033642..79e9efcf06 100644 --- a/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb +++ b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb @@ -7,22 +7,21 @@ class PostgresqlCaseInsensitiveTest < ActiveRecord::PostgreSQLTestCase def test_case_insensitiveness connection = ActiveRecord::Base.connection - table = Default.arel_table - column = Default.columns_hash["char1"] - comparison = connection.case_insensitive_comparison table, :char1, column, nil + attr = Default.arel_attribute(:char1) + comparison = connection.case_insensitive_comparison(attr, nil) assert_match(/lower/i, comparison.to_sql) - column = Default.columns_hash["char2"] - comparison = connection.case_insensitive_comparison table, :char2, column, nil + attr = Default.arel_attribute(:char2) + comparison = connection.case_insensitive_comparison(attr, nil) assert_match(/lower/i, comparison.to_sql) - column = Default.columns_hash["char3"] - comparison = connection.case_insensitive_comparison table, :char3, column, nil + attr = Default.arel_attribute(:char3) + comparison = connection.case_insensitive_comparison(attr, nil) assert_match(/lower/i, comparison.to_sql) - column = Default.columns_hash["multiline_default"] - comparison = connection.case_insensitive_comparison table, :multiline_default, column, nil + attr = Default.arel_attribute(:multiline_default) + comparison = connection.case_insensitive_comparison(attr, nil) assert_match(/lower/i, comparison.to_sql) end end diff --git a/activerecord/test/cases/adapters/postgresql/composite_test.rb b/activerecord/test/cases/adapters/postgresql/composite_test.rb index b0ce2694a3..683066cdb3 100644 --- a/activerecord/test/cases/adapters/postgresql/composite_test.rb +++ b/activerecord/test/cases/adapters/postgresql/composite_test.rb @@ -15,13 +15,13 @@ module PostgresqlCompositeBehavior @connection = ActiveRecord::Base.connection @connection.transaction do - @connection.execute <<-SQL - CREATE TYPE full_address AS - ( - city VARCHAR(90), - street VARCHAR(90) - ); - SQL + @connection.execute <<~SQL + CREATE TYPE full_address AS + ( + city VARCHAR(90), + street VARCHAR(90) + ); + SQL @connection.create_table("postgresql_composites") do |t| t.column :address, :full_address end diff --git a/activerecord/test/cases/adapters/postgresql/connection_test.rb b/activerecord/test/cases/adapters/postgresql/connection_test.rb index d1b3c434e1..210758f462 100644 --- a/activerecord/test/cases/adapters/postgresql/connection_test.rb +++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb @@ -15,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 @@ -24,14 +25,6 @@ module ActiveRecord super end - def test_truncate - count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i - assert_operator count, :>, 0 - ActiveRecord::Base.connection.truncate("comments") - count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i - assert_equal 0, count - end - def test_encoding assert_queries(1) do assert_not_nil @connection.encoding @@ -145,34 +138,15 @@ module ActiveRecord end end - # Must have PostgreSQL >= 9.2, or with_manual_interventions set to - # true for this test to run. - # - # 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, ... - # 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_predicate @connection, :active? - if @connection.send(:postgresql_version) >= 90200 - secondary_connection = ActiveRecord::Base.connection_pool.checkout - secondary_connection.query("select pg_terminate_backend(#{original_connection_pid.first.first})") - ActiveRecord::Base.connection_pool.checkin(secondary_connection) - elsif ARTest.config["with_manual_interventions"] - puts "Kill the connection now (e.g. by restarting the PostgreSQL " \ - 'server with the "-m fast" option) and then press enter.' - $stdin.gets - else - # We're not capable of terminating the backend ourselves, and - # we're not allowed to seek assistance; bail out without - # actually testing anything. - return - end + secondary_connection = ActiveRecord::Base.connection_pool.checkout + secondary_connection.query("select pg_terminate_backend(#{original_connection_pid.first.first})") + ActiveRecord::Base.connection_pool.checkin(secondary_connection) @connection.verify! @@ -229,7 +203,7 @@ module ActiveRecord def test_get_and_release_advisory_lock lock_id = 5295901941911233559 - list_advisory_locks = <<-SQL + list_advisory_locks = <<~SQL SELECT locktype, (classid::bigint << 32) | objid::bigint AS lock_id FROM pg_locks @@ -260,6 +234,10 @@ module ActiveRecord end end + def test_supports_ranges_is_deprecated + assert_deprecated { @connection.supports_ranges? } + end + private def with_warning_suppression diff --git a/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb b/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb new file mode 100644 index 0000000000..a02bae1453 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class UnloggedTablesTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + TABLE_NAME = "things" + LOGGED_FIELD = "relpersistence" + LOGGED_QUERY = "SELECT #{LOGGED_FIELD} FROM pg_class WHERE relname = '#{TABLE_NAME}'" + LOGGED = "p" + UNLOGGED = "u" + TEMPORARY = "t" + + class Thing < ActiveRecord::Base + self.table_name = TABLE_NAME + end + + def setup + @connection = ActiveRecord::Base.connection + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false + end + + teardown do + @connection.drop_table TABLE_NAME, if_exists: true + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false + end + + def test_logged_by_default + @connection.create_table(TABLE_NAME) do |t| + end + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], LOGGED + end + + def test_unlogged_in_test_environment_when_unlogged_setting_enabled + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + + @connection.create_table(TABLE_NAME) do |t| + end + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], UNLOGGED + end + + def test_not_included_in_schema_dump + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + + @connection.create_table(TABLE_NAME) do |t| + end + assert_no_match(/unlogged/i, dump_table_schema(TABLE_NAME)) + end + + def test_not_changed_in_change_table + @connection.create_table(TABLE_NAME) do |t| + end + + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + + @connection.change_table(TABLE_NAME) do |t| + t.column :name, :string + end + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], LOGGED + end + + def test_gracefully_handles_temporary_tables + @connection.create_table(TABLE_NAME, temporary: true) do |t| + end + + # Temporary tables are already unlogged, though this query results in a + # different result ("t" vs. "u"). This test is really just checking that we + # didn't try to run `CREATE TEMPORARY UNLOGGED TABLE`, which would result in + # a PostgreSQL error. + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], TEMPORARY + end +end diff --git a/activerecord/test/cases/adapters/postgresql/datatype_test.rb b/activerecord/test/cases/adapters/postgresql/datatype_test.rb index b7535d5c9a..562cf1f2d1 100644 --- a/activerecord/test/cases/adapters/postgresql/datatype_test.rb +++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb @@ -64,7 +64,7 @@ class PostgresqlDataTypeTest < ActiveRecord::PostgreSQLTestCase def test_text_columns_are_limitless_the_upper_limit_is_one_GB assert_equal "text", @connection.type_to_sql(:text, limit: 100_000) - assert_raise ActiveRecord::ActiveRecordError do + assert_raise ArgumentError do @connection.type_to_sql(:text, limit: 4294967295) end end diff --git a/activerecord/test/cases/adapters/postgresql/enum_test.rb b/activerecord/test/cases/adapters/postgresql/enum_test.rb index 6789ff63e7..416a2b141b 100644 --- a/activerecord/test/cases/adapters/postgresql/enum_test.rb +++ b/activerecord/test/cases/adapters/postgresql/enum_test.rb @@ -13,7 +13,7 @@ class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase def setup @connection = ActiveRecord::Base.connection @connection.transaction do - @connection.execute <<-SQL + @connection.execute <<~SQL CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); SQL @connection.create_table("postgresql_enums") do |t| diff --git a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb index df97ab11e7..0fd7b2c6ed 100644 --- a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb +++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb @@ -22,23 +22,26 @@ class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase @connection = ActiveRecord::Base.connection - @old_schema_migration_table_name = ActiveRecord::SchemaMigration.table_name @old_table_name_prefix = ActiveRecord::Base.table_name_prefix @old_table_name_suffix = ActiveRecord::Base.table_name_suffix ActiveRecord::Base.table_name_prefix = "p_" ActiveRecord::Base.table_name_suffix = "_s" + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name + ActiveRecord::SchemaMigration.delete_all rescue nil - ActiveRecord::SchemaMigration.table_name = "p_schema_migrations_s" ActiveRecord::Migration.verbose = false end def teardown - ActiveRecord::Base.table_name_prefix = @old_table_name_prefix - ActiveRecord::Base.table_name_suffix = @old_table_name_suffix ActiveRecord::SchemaMigration.delete_all rescue nil ActiveRecord::Migration.verbose = true - ActiveRecord::SchemaMigration.table_name = @old_schema_migration_table_name + + ActiveRecord::Base.table_name_prefix = @old_table_name_prefix + ActiveRecord::Base.table_name_suffix = @old_table_name_suffix + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name super end diff --git a/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb index 4fa315ad23..69339c8a31 100644 --- a/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb +++ b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb @@ -22,18 +22,18 @@ if ActiveRecord::Base.connection.supports_foreign_tables? enable_extension!("postgres_fdw", @connection) foreign_db_config = ARTest.connection_config["arunit2"] - @connection.execute <<-SQL + @connection.execute <<~SQL CREATE SERVER foreign_server FOREIGN DATA WRAPPER postgres_fdw OPTIONS (dbname '#{foreign_db_config["database"]}') SQL - @connection.execute <<-SQL + @connection.execute <<~SQL CREATE USER MAPPING FOR CURRENT_USER SERVER foreign_server SQL - @connection.execute <<-SQL + @connection.execute <<~SQL CREATE FOREIGN TABLE foreign_professors ( id int, name character varying NOT NULL @@ -45,7 +45,7 @@ if ActiveRecord::Base.connection.supports_foreign_tables? def teardown disable_extension!("postgres_fdw", @connection) - @connection.execute <<-SQL + @connection.execute <<~SQL DROP SERVER IF EXISTS foreign_server CASCADE SQL end diff --git a/activerecord/test/cases/adapters/postgresql/geometric_test.rb b/activerecord/test/cases/adapters/postgresql/geometric_test.rb index 8c6f046553..14c262f4ce 100644 --- a/activerecord/test/cases/adapters/postgresql/geometric_test.rb +++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb @@ -247,7 +247,7 @@ class PostgreSQLGeometricLineTest < ActiveRecord::PostgreSQLTestCase class PostgresqlLine < ActiveRecord::Base; end setup do - unless ActiveRecord::Base.connection.send(:postgresql_version) >= 90400 + unless ActiveRecord::Base.connection.database_version >= 90400 skip("line type is not fully implemented") end @connection = ActiveRecord::Base.connection diff --git a/activerecord/test/cases/adapters/postgresql/hstore_test.rb b/activerecord/test/cases/adapters/postgresql/hstore_test.rb index 4b061a9375..671d8211a7 100644 --- a/activerecord/test/cases/adapters/postgresql/hstore_test.rb +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "support/schema_dumping_helper" +require "support/stubs/strong_parameters" class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase include SchemaDumpingHelper @@ -11,12 +12,6 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase store_accessor :settings, :language, :timezone end - class FakeParameters - def to_unsafe_h - { "hi" => "hi" } - end - end - def setup @connection = ActiveRecord::Base.connection @@ -158,6 +153,22 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase assert_equal "GMT", y.timezone end + def test_changes_with_store_accessors + x = Hstore.new(language: "de") + assert x.language_changed? + assert_nil x.language_was + assert_equal [nil, "de"], x.language_change + x.save! + + assert_not x.language_changed? + x.reload + + x.settings = nil + assert x.language_changed? + assert_equal "de", x.language_was + assert_equal ["de", nil], x.language_change + end + def test_changes_in_place hstore = Hstore.create!(settings: { "one" => "two" }) hstore.settings["three"] = "four" @@ -344,7 +355,7 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase end def test_supports_to_unsafe_h_values - assert_equal("\"hi\"=>\"hi\"", @type.serialize(FakeParameters.new)) + assert_equal "\"hi\"=>\"hi\"", @type.serialize(ProtectedParams.new("hi" => "hi")) end private diff --git a/activerecord/test/cases/adapters/postgresql/infinity_test.rb b/activerecord/test/cases/adapters/postgresql/infinity_test.rb index 5e56ce8427..b1bf06d9e9 100644 --- a/activerecord/test/cases/adapters/postgresql/infinity_test.rb +++ b/activerecord/test/cases/adapters/postgresql/infinity_test.rb @@ -71,17 +71,15 @@ class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase end test "assigning 'infinity' on a datetime column with TZ aware attributes" do - begin - in_time_zone "Pacific Time (US & Canada)" do - record = PostgresqlInfinity.create!(datetime: "infinity") - assert_equal Float::INFINITY, record.datetime - assert_equal record.datetime, record.reload.datetime - end - ensure - # setting time_zone_aware_attributes causes the types to change. - # There is no way to do this automatically since it can be set on a superclass - PostgresqlInfinity.reset_column_information + in_time_zone "Pacific Time (US & Canada)" do + record = PostgresqlInfinity.create!(datetime: "infinity") + assert_equal Float::INFINITY, record.datetime + assert_equal record.datetime, record.reload.datetime end + ensure + # setting time_zone_aware_attributes causes the types to change. + # There is no way to do this automatically since it can be set on a superclass + PostgresqlInfinity.reset_column_information end test "where clause with infinite range on a datetime column" do diff --git a/activerecord/test/cases/adapters/postgresql/money_test.rb b/activerecord/test/cases/adapters/postgresql/money_test.rb index be3590e8dd..1aa0348879 100644 --- a/activerecord/test/cases/adapters/postgresql/money_test.rb +++ b/activerecord/test/cases/adapters/postgresql/money_test.rb @@ -6,7 +6,9 @@ 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 @@ -35,6 +37,7 @@ class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase def test_default 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 @@ -49,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".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)) + 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)")) end def test_schema_dumping @@ -62,7 +65,7 @@ class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase end def test_create_and_update_money - money = PostgresqlMoney.create(wealth: "987.65".dup) + money = PostgresqlMoney.create(wealth: +"987.65") assert_equal 987.65, money.wealth new_value = BigDecimal("123.45") diff --git a/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb new file mode 100644 index 0000000000..5b9f5e0832 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +if supports_optimizer_hints? + class PostgresqlOptimzerHintsTest < ActiveRecord::PostgreSQLTestCase + fixtures :posts + + def setup + enable_extension!("pg_hint_plan", ActiveRecord::Base.connection) + end + + def test_optimizer_hints + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("SeqScan(posts)") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "Seq Scan on posts" + end + end + + def test_optimizer_hints_with_count_subquery + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("SeqScan(posts)") + posts = posts.select(:id).where(author_id: [0, 1]).limit(5) + assert_equal 5, posts.count + end + end + + def test_optimizer_hints_is_sanitized + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("/*+ SeqScan(posts) */") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_includes posts.explain, "Seq Scan on posts" + end + + assert_sql(%r{\ASELECT /\*\+ "posts"\.\*, \*/}) do + posts = Post.optimizer_hints("**// \"posts\".*, //**") + posts = posts.select(:id).where(author_id: [0, 1]) + assert_equal({ "id" => 1 }, posts.first.as_json) + end + end + + def test_optimizer_hints_with_unscope + assert_sql(%r{\ASELECT "posts"\."id"}) do + posts = Post.optimizer_hints("/*+ SeqScan(posts) */") + posts = posts.select(:id).where(author_id: [0, 1]) + posts.unscope(:optimizer_hints).load + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb new file mode 100644 index 0000000000..4015bc94f9 --- /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.database_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 cbb6cd42b5..fbd3cbf90f 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -376,6 +376,72 @@ module ActiveRecord end end + def test_errors_when_an_insert_query_is_called_while_preventing_writes + with_example_table do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + end + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'") + end + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.execute("DELETE FROM ex where data = '138853948594'") + end + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @connection.while_preventing_writes do + assert_equal 1, @connection.execute("SELECT * FROM ex WHERE data = '138853948594'").entries.count + end + end + end + + def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes + @connection.while_preventing_writes do + assert_equal 1, @connection.execute("SHOW TIME ZONE").entries.count + end + end + + def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes + @connection.while_preventing_writes do + assert_equal [], @connection.execute("SET standard_conforming_strings = on").entries + end + end + + def test_doesnt_error_when_a_read_query_with_leading_chars_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @connection.while_preventing_writes do + assert_equal 1, @connection.execute("(\n( SELECT * FROM ex WHERE data = '138853948594' ) )").entries.count + end + end + end + private def with_example_table(definition = "id serial primary key, number integer, data character varying(255)", &block) diff --git a/activerecord/test/cases/adapters/postgresql/quoting_test.rb b/activerecord/test/cases/adapters/postgresql/quoting_test.rb index d50dc49276..d571355a9c 100644 --- a/activerecord/test/cases/adapters/postgresql/quoting_test.rb +++ b/activerecord/test/cases/adapters/postgresql/quoting_test.rb @@ -39,6 +39,11 @@ module ActiveRecord type = OID::Bit.new assert_nil @conn.quote(type.serialize(value)) end + + def test_quote_table_name_with_spaces + value = "user posts" + assert_equal "\"user posts\"", @conn.quote_table_name(value) + end end end end diff --git a/activerecord/test/cases/adapters/postgresql/range_test.rb b/activerecord/test/cases/adapters/postgresql/range_test.rb index 261c24634e..068f1e8bea 100644 --- a/activerecord/test/cases/adapters/postgresql/range_test.rb +++ b/activerecord/test/cases/adapters/postgresql/range_test.rb @@ -3,412 +3,432 @@ require "cases/helper" require "support/connection_helper" -if ActiveRecord::Base.connection.respond_to?(:supports_ranges?) && ActiveRecord::Base.connection.supports_ranges? - class PostgresqlRange < ActiveRecord::Base - self.table_name = "postgresql_ranges" - self.time_zone_aware_types += [:tsrange, :tstzrange] - end - - class PostgresqlRangeTest < ActiveRecord::PostgreSQLTestCase - self.use_transactional_tests = false - include ConnectionHelper - include InTimeZone - - def setup - @connection = PostgresqlRange.connection - begin - @connection.transaction do - @connection.execute <<_SQL - CREATE TYPE floatrange AS RANGE ( - subtype = float8, - subtype_diff = float8mi - ); -_SQL - - @connection.create_table("postgresql_ranges") do |t| - t.daterange :date_range - t.numrange :num_range - t.tsrange :ts_range - t.tstzrange :tstz_range - t.int4range :int4_range - t.int8range :int8_range - end - - @connection.add_column "postgresql_ranges", "float_range", "floatrange" - end - PostgresqlRange.reset_column_information - rescue ActiveRecord::StatementInvalid - skip "do not test on PG without range" - end +class PostgresqlRange < ActiveRecord::Base + self.table_name = "postgresql_ranges" + self.time_zone_aware_types += [:tsrange, :tstzrange] +end - insert_range(id: 101, - date_range: "[''2012-01-02'', ''2012-01-04'']", - num_range: "[0.1, 0.2]", - ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'']", - tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']", - int4_range: "[1, 10]", - int8_range: "[10, 100]", - float_range: "[0.5, 0.7]") - - insert_range(id: 102, - date_range: "[''2012-01-02'', ''2012-01-04'')", - num_range: "[0.1, 0.2)", - ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'')", - tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'')", - int4_range: "[1, 10)", - int8_range: "[10, 100)", - float_range: "[0.5, 0.7)") - - insert_range(id: 103, - date_range: "[''2012-01-02'',]", - num_range: "[0.1,]", - ts_range: "[''2010-01-01 14:30'',]", - tstz_range: "[''2010-01-01 14:30:00+05'',]", - int4_range: "[1,]", - int8_range: "[10,]", - float_range: "[0.5,]") - - insert_range(id: 104, - date_range: "[,]", - num_range: "[,]", - ts_range: "[,]", - tstz_range: "[,]", - int4_range: "[,]", - int8_range: "[,]", - float_range: "[,]") - - insert_range(id: 105, - date_range: "[''2012-01-02'', ''2012-01-02'')", - num_range: "[0.1, 0.1)", - ts_range: "[''2010-01-01 14:30'', ''2010-01-01 14:30'')", - tstz_range: "[''2010-01-01 14:30:00+05'', ''2010-01-01 06:30:00-03'')", - int4_range: "[1, 1)", - int8_range: "[10, 10)", - float_range: "[0.5, 0.5)") - - @new_range = PostgresqlRange.new - @first_range = PostgresqlRange.find(101) - @second_range = PostgresqlRange.find(102) - @third_range = PostgresqlRange.find(103) - @fourth_range = PostgresqlRange.find(104) - @empty_range = PostgresqlRange.find(105) - end +class PostgresqlRangeTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + include ConnectionHelper + include InTimeZone + + def setup + @connection = PostgresqlRange.connection + begin + @connection.transaction do + @connection.execute <<~SQL + CREATE TYPE floatrange AS RANGE ( + subtype = float8, + subtype_diff = float8mi + ); + SQL - teardown do - @connection.drop_table "postgresql_ranges", if_exists: true - @connection.execute "DROP TYPE IF EXISTS floatrange" - reset_connection - end + @connection.create_table("postgresql_ranges") do |t| + t.daterange :date_range + t.numrange :num_range + t.tsrange :ts_range + t.tstzrange :tstz_range + t.int4range :int4_range + t.int8range :int8_range + end - def test_data_type_of_range_types - assert_equal :daterange, @first_range.column_for_attribute(:date_range).type - assert_equal :numrange, @first_range.column_for_attribute(:num_range).type - assert_equal :tsrange, @first_range.column_for_attribute(:ts_range).type - assert_equal :tstzrange, @first_range.column_for_attribute(:tstz_range).type - assert_equal :int4range, @first_range.column_for_attribute(:int4_range).type - assert_equal :int8range, @first_range.column_for_attribute(:int8_range).type - end + @connection.add_column "postgresql_ranges", "float_range", "floatrange" + end + PostgresqlRange.reset_column_information + rescue ActiveRecord::StatementInvalid + skip "do not test on PG without range" + end + + insert_range(id: 101, + date_range: "[''2012-01-02'', ''2012-01-04'']", + num_range: "[0.1, 0.2]", + ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'']", + tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']", + int4_range: "[1, 10]", + int8_range: "[10, 100]", + float_range: "[0.5, 0.7]") + + insert_range(id: 102, + date_range: "[''2012-01-02'', ''2012-01-04'')", + num_range: "[0.1, 0.2)", + ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'')", + tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'')", + int4_range: "[1, 10)", + int8_range: "[10, 100)", + float_range: "[0.5, 0.7)") + + insert_range(id: 103, + date_range: "[''2012-01-02'',]", + num_range: "[0.1,]", + ts_range: "[''2010-01-01 14:30'',]", + tstz_range: "[''2010-01-01 14:30:00+05'',]", + int4_range: "[1,]", + int8_range: "[10,]", + float_range: "[0.5,]") + + insert_range(id: 104, + date_range: "[,]", + num_range: "[,]", + ts_range: "[,]", + tstz_range: "[,]", + int4_range: "[,]", + int8_range: "[,]", + float_range: "[,]") + + insert_range(id: 105, + date_range: "[''2012-01-02'', ''2012-01-02'')", + num_range: "[0.1, 0.1)", + ts_range: "[''2010-01-01 14:30'', ''2010-01-01 14:30'')", + tstz_range: "[''2010-01-01 14:30:00+05'', ''2010-01-01 06:30:00-03'')", + int4_range: "[1, 1)", + int8_range: "[10, 10)", + float_range: "[0.5, 0.5)") + + @new_range = PostgresqlRange.new + @first_range = PostgresqlRange.find(101) + @second_range = PostgresqlRange.find(102) + @third_range = PostgresqlRange.find(103) + @fourth_range = PostgresqlRange.find(104) + @empty_range = PostgresqlRange.find(105) + end - def test_int4range_values - assert_equal 1...11, @first_range.int4_range - assert_equal 1...10, @second_range.int4_range - assert_equal 1...Float::INFINITY, @third_range.int4_range - assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int4_range) - assert_nil @empty_range.int4_range - end + teardown do + @connection.drop_table "postgresql_ranges", if_exists: true + @connection.execute "DROP TYPE IF EXISTS floatrange" + reset_connection + end - def test_int8range_values - assert_equal 10...101, @first_range.int8_range - assert_equal 10...100, @second_range.int8_range - assert_equal 10...Float::INFINITY, @third_range.int8_range - assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int8_range) - assert_nil @empty_range.int8_range - end + def test_data_type_of_range_types + assert_equal :daterange, @first_range.column_for_attribute(:date_range).type + assert_equal :numrange, @first_range.column_for_attribute(:num_range).type + assert_equal :tsrange, @first_range.column_for_attribute(:ts_range).type + assert_equal :tstzrange, @first_range.column_for_attribute(:tstz_range).type + assert_equal :int4range, @first_range.column_for_attribute(:int4_range).type + assert_equal :int8range, @first_range.column_for_attribute(:int8_range).type + end - def test_daterange_values - assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 5), @first_range.date_range - assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 4), @second_range.date_range - assert_equal Date.new(2012, 1, 2)...Float::INFINITY, @third_range.date_range - assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.date_range) - assert_nil @empty_range.date_range - end + def test_int4range_values + assert_equal 1...11, @first_range.int4_range + assert_equal 1...10, @second_range.int4_range + assert_equal 1...Float::INFINITY, @third_range.int4_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int4_range) + assert_nil @empty_range.int4_range + end - def test_numrange_values - 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 + def test_int8range_values + assert_equal 10...101, @first_range.int8_range + assert_equal 10...100, @second_range.int8_range + assert_equal 10...Float::INFINITY, @third_range.int8_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int8_range) + assert_nil @empty_range.int8_range + end - def test_tsrange_values - tz = ::ActiveRecord::Base.default_timezone - assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)..Time.send(tz, 2011, 1, 1, 14, 30, 0), @first_range.ts_range - assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 1, 1, 14, 30, 0), @second_range.ts_range - assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.ts_range) - assert_nil @empty_range.ts_range - end + def test_daterange_values + assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 5), @first_range.date_range + assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 4), @second_range.date_range + assert_equal Date.new(2012, 1, 2)...Float::INFINITY, @third_range.date_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.date_range) + assert_nil @empty_range.date_range + end - def test_tstzrange_values - assert_equal Time.parse("2010-01-01 09:30:00 UTC")..Time.parse("2011-01-01 17:30:00 UTC"), @first_range.tstz_range - assert_equal Time.parse("2010-01-01 09:30:00 UTC")...Time.parse("2011-01-01 17:30:00 UTC"), @second_range.tstz_range - assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.tstz_range) - assert_nil @empty_range.tstz_range - end + def test_numrange_values + 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 - def test_custom_range_values - assert_equal 0.5..0.7, @first_range.float_range - assert_equal 0.5...0.7, @second_range.float_range - assert_equal 0.5...Float::INFINITY, @third_range.float_range - assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.float_range) - assert_nil @empty_range.float_range - end + def test_tsrange_values + tz = ::ActiveRecord::Base.default_timezone + assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)..Time.send(tz, 2011, 1, 1, 14, 30, 0), @first_range.ts_range + assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 1, 1, 14, 30, 0), @second_range.ts_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.ts_range) + assert_nil @empty_range.ts_range + end - def test_timezone_awareness_tzrange - tz = "Pacific Time (US & Canada)" + def test_tstzrange_values + assert_equal Time.parse("2010-01-01 09:30:00 UTC")..Time.parse("2011-01-01 17:30:00 UTC"), @first_range.tstz_range + assert_equal Time.parse("2010-01-01 09:30:00 UTC")...Time.parse("2011-01-01 17:30:00 UTC"), @second_range.tstz_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.tstz_range) + assert_nil @empty_range.tstz_range + end - in_time_zone tz do - PostgresqlRange.reset_column_information - time_string = Time.current.to_s - time = Time.zone.parse(time_string) + def test_custom_range_values + assert_equal 0.5..0.7, @first_range.float_range + assert_equal 0.5...0.7, @second_range.float_range + assert_equal 0.5...Float::INFINITY, @third_range.float_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.float_range) + assert_nil @empty_range.float_range + end - record = PostgresqlRange.new(tstz_range: time_string..time_string) - assert_equal time..time, record.tstz_range - assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone + def test_timezone_awareness_tzrange + tz = "Pacific Time (US & Canada)" - record.save! - record.reload + in_time_zone tz do + PostgresqlRange.reset_column_information + time_string = Time.current.to_s + time = Time.zone.parse(time_string) - assert_equal time..time, record.tstz_range - assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone - end - end + record = PostgresqlRange.new(tstz_range: time_string..time_string) + assert_equal time..time, record.tstz_range + assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone - def test_create_tstzrange - tstzrange = Time.parse("2010-01-01 14:30:00 +0100")...Time.parse("2011-02-02 14:30:00 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 UTC")...Time.parse("2011-02-02 19:30:00 UTC") - end + record.save! + record.reload - def test_update_tstzrange - assert_equal_round_trip(@first_range, :tstz_range, - Time.parse("2010-01-01 14:30:00 CDT")...Time.parse("2011-02-02 14:30:00 CET")) - assert_nil_round_trip(@first_range, :tstz_range, - Time.parse("2010-01-01 14:30:00 +0100")...Time.parse("2010-01-01 13:30:00 +0000")) + assert_equal time..time, record.tstz_range + assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone end + end - def test_create_tsrange - tz = ::ActiveRecord::Base.default_timezone - assert_equal_round_trip(@new_range, :ts_range, - Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0)) - end + def test_create_tstzrange + tstzrange = Time.parse("2010-01-01 14:30:00 +0100")...Time.parse("2011-02-02 14:30:00 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 UTC")...Time.parse("2011-02-02 19:30:00 UTC") + end - def test_update_tsrange - tz = ::ActiveRecord::Base.default_timezone - assert_equal_round_trip(@first_range, :ts_range, - Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0)) - assert_nil_round_trip(@first_range, :ts_range, - Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2010, 1, 1, 14, 30, 0)) - end + def test_update_tstzrange + assert_equal_round_trip(@first_range, :tstz_range, + Time.parse("2010-01-01 14:30:00 CDT")...Time.parse("2011-02-02 14:30:00 CET")) + assert_nil_round_trip(@first_range, :tstz_range, + Time.parse("2010-01-01 14:30:00 +0100")...Time.parse("2010-01-01 13:30:00 +0000")) + end - def test_timezone_awareness_tsrange - tz = "Pacific Time (US & Canada)" + def test_create_tsrange + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@new_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0)) + end - in_time_zone tz do - PostgresqlRange.reset_column_information - time_string = Time.current.to_s - time = Time.zone.parse(time_string) + def test_update_tsrange + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0)) + assert_nil_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2010, 1, 1, 14, 30, 0)) + end - 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 + def test_timezone_awareness_tsrange + tz = "Pacific Time (US & Canada)" - record.save! - record.reload + in_time_zone tz do + PostgresqlRange.reset_column_information + time_string = Time.current.to_s + time = Time.zone.parse(time_string) - assert_equal time..time, record.ts_range - assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone - end - end + 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 - 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 + record.save! + record.reload - 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")) + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone end + 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_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_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_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_timezone_awareness_tsrange_preserve_usec - tz = "Pacific Time (US & Canada)" + 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 - 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 + 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 - 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 + def test_timezone_awareness_tsrange_preserve_usec + tz = "Pacific Time (US & Canada)" - record.save! - record.reload + 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 - 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 + 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 - def test_create_numrange - assert_equal_round_trip(@new_range, :num_range, - BigDecimal("0.5")...BigDecimal("1")) - end + record.save! + record.reload - def test_update_numrange - assert_equal_round_trip(@first_range, :num_range, - BigDecimal("0.5")...BigDecimal("1")) - assert_nil_round_trip(@first_range, :num_range, - BigDecimal("0.5")...BigDecimal("0.5")) + 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_daterange - assert_equal_round_trip(@new_range, :date_range, - Range.new(Date.new(2012, 1, 1), Date.new(2013, 1, 1), true)) - end + def test_create_numrange + assert_equal_round_trip(@new_range, :num_range, + BigDecimal("0.5")...BigDecimal("1")) + end - def test_update_daterange - assert_equal_round_trip(@first_range, :date_range, - Date.new(2012, 2, 3)...Date.new(2012, 2, 10)) - assert_nil_round_trip(@first_range, :date_range, - Date.new(2012, 2, 3)...Date.new(2012, 2, 3)) - end + def test_update_numrange + assert_equal_round_trip(@first_range, :num_range, + BigDecimal("0.5")...BigDecimal("1")) + assert_nil_round_trip(@first_range, :num_range, + BigDecimal("0.5")...BigDecimal("0.5")) + end - def test_create_int4range - assert_equal_round_trip(@new_range, :int4_range, Range.new(3, 50, true)) - end + def test_create_daterange + assert_equal_round_trip(@new_range, :date_range, + Range.new(Date.new(2012, 1, 1), Date.new(2013, 1, 1), true)) + end - def test_update_int4range - assert_equal_round_trip(@first_range, :int4_range, 6...10) - assert_nil_round_trip(@first_range, :int4_range, 3...3) - end + def test_update_daterange + assert_equal_round_trip(@first_range, :date_range, + Date.new(2012, 2, 3)...Date.new(2012, 2, 10)) + assert_nil_round_trip(@first_range, :date_range, + Date.new(2012, 2, 3)...Date.new(2012, 2, 3)) + end - def test_create_int8range - assert_equal_round_trip(@new_range, :int8_range, Range.new(30, 50, true)) - end + def test_create_int4range + assert_equal_round_trip(@new_range, :int4_range, Range.new(3, 50, true)) + end - def test_update_int8range - assert_equal_round_trip(@first_range, :int8_range, 60000...10000000) - assert_nil_round_trip(@first_range, :int8_range, 39999...39999) - end + def test_update_int4range + assert_equal_round_trip(@first_range, :int4_range, 6...10) + assert_nil_round_trip(@first_range, :int4_range, 3...3) + end - def test_exclude_beginning_for_subtypes_without_succ_method_is_not_supported - assert_raises(ArgumentError) { PostgresqlRange.create!(num_range: "(0.1, 0.2]") } - assert_raises(ArgumentError) { PostgresqlRange.create!(float_range: "(0.5, 0.7]") } - assert_raises(ArgumentError) { PostgresqlRange.create!(int4_range: "(1, 10]") } - assert_raises(ArgumentError) { PostgresqlRange.create!(int8_range: "(10, 100]") } - assert_raises(ArgumentError) { PostgresqlRange.create!(date_range: "(''2012-01-02'', ''2012-01-04'']") } - assert_raises(ArgumentError) { PostgresqlRange.create!(ts_range: "(''2010-01-01 14:30'', ''2011-01-01 14:30'']") } - assert_raises(ArgumentError) { PostgresqlRange.create!(tstz_range: "(''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']") } - end + def test_create_int8range + assert_equal_round_trip(@new_range, :int8_range, Range.new(30, 50, true)) + end - def test_where_by_attribute_with_range - range = 1..100 - record = PostgresqlRange.create!(int4_range: range) - assert_equal record, PostgresqlRange.where(int4_range: range).take - end + def test_update_int8range + assert_equal_round_trip(@first_range, :int8_range, 60000...10000000) + assert_nil_round_trip(@first_range, :int8_range, 39999...39999) + end - def test_update_all_with_ranges - PostgresqlRange.create! + def test_exclude_beginning_for_subtypes_without_succ_method_is_not_supported + assert_raises(ArgumentError) { PostgresqlRange.create!(num_range: "(0.1, 0.2]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(float_range: "(0.5, 0.7]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(int4_range: "(1, 10]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(int8_range: "(10, 100]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(date_range: "(''2012-01-02'', ''2012-01-04'']") } + assert_raises(ArgumentError) { PostgresqlRange.create!(ts_range: "(''2010-01-01 14:30'', ''2011-01-01 14:30'']") } + assert_raises(ArgumentError) { PostgresqlRange.create!(tstz_range: "(''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']") } + end - PostgresqlRange.update_all(int8_range: 1..100) + def test_where_by_attribute_with_range + range = 1..100 + record = PostgresqlRange.create!(int4_range: range) + assert_equal record, PostgresqlRange.where(int4_range: range).take + end - assert_equal 1...101, PostgresqlRange.first.int8_range - 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_ranges_correctly_escape_input - range = "-1,2]'; DROP TABLE postgresql_ranges; --".."a" - PostgresqlRange.update_all(int8_range: range) + def test_update_all_with_ranges + PostgresqlRange.create! - assert_nothing_raised do - PostgresqlRange.first - end - end + PostgresqlRange.update_all(int8_range: 1..100) - def test_infinity_values - PostgresqlRange.create!(int4_range: 1..Float::INFINITY, - int8_range: -Float::INFINITY..0, - float_range: -Float::INFINITY..Float::INFINITY) + assert_equal 1...101, PostgresqlRange.first.int8_range + end - record = PostgresqlRange.first + def test_ranges_correctly_escape_input + range = "-1,2]'; DROP TABLE postgresql_ranges; --".."a" + PostgresqlRange.update_all(int8_range: range) - 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) + assert_nothing_raised do + PostgresqlRange.first end + end - private - def assert_equal_round_trip(range, attribute, value) - round_trip(range, attribute, value) - assert_equal value, range.public_send(attribute) - end + def test_infinity_values + PostgresqlRange.create!(int4_range: 1..Float::INFINITY, + int8_range: -Float::INFINITY..0, + float_range: -Float::INFINITY..Float::INFINITY) - def assert_nil_round_trip(range, attribute, value) - round_trip(range, attribute, value) - assert_nil range.public_send(attribute) - end + record = PostgresqlRange.first - def round_trip(range, attribute, value) - range.public_send "#{attribute}=", value - assert range.save - assert range.reload - end + 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 - def insert_range(values) - @connection.execute <<-SQL - INSERT INTO postgresql_ranges ( - id, - date_range, - num_range, - ts_range, - tstz_range, - int4_range, - int8_range, - float_range - ) VALUES ( - #{values[:id]}, - '#{values[:date_range]}', - '#{values[:num_range]}', - '#{values[:ts_range]}', - '#{values[:tstz_range]}', - '#{values[:int4_range]}', - '#{values[:int8_range]}', - '#{values[:float_range]}' - ) - SQL - end + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.6.0") + def test_endless_range_values + record = PostgresqlRange.create!( + int4_range: eval("1.."), + int8_range: eval("10.."), + float_range: eval("0.5..") + ) + + record = PostgresqlRange.find(record.id) + + assert_equal 1...Float::INFINITY, record.int4_range + assert_equal 10...Float::INFINITY, record.int8_range + assert_equal 0.5...Float::INFINITY, record.float_range + end end + + private + def assert_equal_round_trip(range, attribute, value) + round_trip(range, attribute, value) + assert_equal value, range.public_send(attribute) + end + + def assert_nil_round_trip(range, attribute, value) + round_trip(range, attribute, value) + assert_nil range.public_send(attribute) + end + + def round_trip(range, attribute, value) + range.public_send "#{attribute}=", value + assert range.save + assert range.reload + end + + def insert_range(values) + @connection.execute <<~SQL + INSERT INTO postgresql_ranges ( + id, + date_range, + num_range, + ts_range, + tstz_range, + int4_range, + int8_range, + float_range + ) VALUES ( + #{values[:id]}, + '#{values[:date_range]}', + '#{values[:num_range]}', + '#{values[:ts_range]}', + '#{values[:tstz_range]}', + '#{values[:int4_range]}', + '#{values[:int8_range]}', + '#{values[:float_range]}' + ) + SQL + end end diff --git a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb index 0bcc214c24..ba477c63f4 100644 --- a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb +++ b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb @@ -101,7 +101,7 @@ class PostgreSQLReferentialIntegrityTest < ActiveRecord::PostgreSQLTestCase @connection.extend ProgrammerMistake assert_raises ArgumentError do - @connection.disable_referential_integrity {} + @connection.disable_referential_integrity { } end end diff --git a/activerecord/test/cases/adapters/postgresql/rename_table_test.rb b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb index 100d247113..7eccaf4aa2 100644 --- a/activerecord/test/cases/adapters/postgresql/rename_table_test.rb +++ b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb @@ -27,7 +27,7 @@ class PostgresqlRenameTableTest < ActiveRecord::PostgreSQLTestCase private def num_indices_named(name) - @connection.execute(<<-SQL).values.length + @connection.execute(<<~SQL).values.length SELECT 1 FROM "pg_index" JOIN "pg_class" ON "pg_index"."indexrelid" = "pg_class"."oid" WHERE "pg_class"."relname" = '#{name}' diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb index 7ad03c194f..336cec30ca 100644 --- a/activerecord/test/cases/adapters/postgresql/schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb @@ -108,23 +108,19 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase end def test_create_schema - begin - @connection.create_schema "test_schema3" - assert @connection.schema_names.include? "test_schema3" - ensure - @connection.drop_schema "test_schema3" - end + @connection.create_schema "test_schema3" + assert @connection.schema_names.include? "test_schema3" + ensure + @connection.drop_schema "test_schema3" end def test_raise_create_schema_with_existing_schema - begin + @connection.create_schema "test_schema3" + assert_raises(ActiveRecord::StatementInvalid) do @connection.create_schema "test_schema3" - assert_raises(ActiveRecord::StatementInvalid) do - @connection.create_schema "test_schema3" - end - ensure - @connection.drop_schema "test_schema3" end + ensure + @connection.drop_schema "test_schema3" end def test_drop_schema @@ -146,7 +142,7 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase def test_habtm_table_name_with_schema ActiveRecord::Base.connection.drop_schema "music", if_exists: true ActiveRecord::Base.connection.create_schema "music" - ActiveRecord::Base.connection.execute <<-SQL + ActiveRecord::Base.connection.execute <<~SQL CREATE TABLE music.albums (id serial primary key); CREATE TABLE music.songs (id serial primary key); CREATE TABLE music.albums_songs (album_id integer, song_id integer); @@ -204,12 +200,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 @@ -507,6 +503,7 @@ class SchemaIndexOpclassTest < ActiveRecord::PostgreSQLTestCase @connection = ActiveRecord::Base.connection @connection.create_table "trains" do |t| t.string :name + t.string :position t.text :description end end @@ -530,6 +527,17 @@ class SchemaIndexOpclassTest < ActiveRecord::PostgreSQLTestCase assert_match(/opclass: \{ description: :text_pattern_ops \}/, output) end + + def test_opclass_class_parsing_on_non_reserved_and_cannot_be_function_or_type_keyword + @connection.enable_extension("pg_trgm") + @connection.execute "CREATE INDEX trains_position ON trains USING gin(position gin_trgm_ops)" + @connection.execute "CREATE INDEX trains_name_and_position ON trains USING btree(name, position text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: :gin_trgm_ops/, output) + assert_match(/opclass: \{ position: :text_pattern_ops \}/, output) + end end class SchemaIndexNullsOrderTest < ActiveRecord::PostgreSQLTestCase diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb index 984b2f5ea4..919ff3d158 100644 --- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb +++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb @@ -94,7 +94,6 @@ module ActiveRecord 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 diff --git a/activerecord/test/cases/adapters/postgresql/uuid_test.rb b/activerecord/test/cases/adapters/postgresql/uuid_test.rb index 71d07e2f4c..d2d8ea8042 100644 --- a/activerecord/test/cases/adapters/postgresql/uuid_test.rb +++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb @@ -114,6 +114,22 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase assert_equal "foobar", uuid.guid_before_type_cast end + def test_invalid_uuid_dont_match_to_nil + UUIDType.create! + assert_empty UUIDType.where(guid: "") + assert_empty UUIDType.where(guid: "foobar") + end + + class DuckUUID + def initialize(uuid) + @uuid = uuid + end + + def to_s + @uuid + end + end + def test_acceptable_uuid_regex # Valid uuids ["A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11", @@ -125,9 +141,11 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase # so we shouldn't block it either. (Pay attention to "fb6d" – the "f" here # is invalid – it must be one of 8, 9, A, B, a, b according to the spec.) "{a0eebc99-9c0b-4ef8-fb6d-6bb9bd380a11}", + # Support Object-Oriented UUIDs which respond to #to_s + DuckUUID.new("A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11"), ].each do |valid_uuid| uuid = UUIDType.new guid: valid_uuid - assert_not_nil uuid.guid + assert_instance_of String, uuid.guid end # Invalid uuids @@ -198,10 +216,10 @@ class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase # Create custom PostgreSQL function to generate UUIDs # to test dumping tables which columns have defaults with custom functions - connection.execute <<-SQL - CREATE OR REPLACE FUNCTION my_uuid_generator() RETURNS uuid - AS $$ SELECT * FROM #{uuid_function} $$ - LANGUAGE SQL VOLATILE; + connection.execute <<~SQL + CREATE OR REPLACE FUNCTION my_uuid_generator() RETURNS uuid + AS $$ SELECT * FROM #{uuid_function} $$ + LANGUAGE SQL VOLATILE; SQL # Create such a table with custom function as default value generator diff --git a/activerecord/test/cases/adapters/sqlite3/annotate_test.rb b/activerecord/test/cases/adapters/sqlite3/annotate_test.rb new file mode 100644 index 0000000000..6567a5eca3 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/annotate_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +class SQLite3AnnotateTest < ActiveRecord::SQLite3TestCase + fixtures :posts + + def test_annotate_wraps_content_in_an_inline_comment + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("foo") + assert posts.first + end + end + + def test_annotate_is_sanitized + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("*/foo/*") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/}) do + posts = Post.select(:id).annotate("**//foo//**") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* foo \*/ /\* bar \*/}) do + posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") + assert posts.first + end + + assert_sql(%r{\ASELECT "posts"\."id" FROM "posts" /\* \+ MAX_EXECUTION_TIME\(1\) \*/}) do + posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") + assert posts.first + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb new file mode 100644 index 0000000000..93a7dafebd --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +module ActiveRecord + module ConnectionAdapters + class SQLite3Adapter + class BindParameterTest < ActiveRecord::SQLite3TestCase + def test_too_many_binds + topics = Topic.where(id: (1..999).to_a << 2**63) + assert_equal Topic.count, topics.count + + topics = Topic.where.not(id: (1..999).to_a << 2**63) + assert_equal 0, topics.count + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb index 1c85ff5674..9d26f32102 100644 --- a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb @@ -6,12 +6,8 @@ require "securerandom" class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase def setup + super @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 @@ -22,18 +18,10 @@ 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 @@ -62,4 +50,30 @@ class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase 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 7e0ce3a28e..806cfbfc00 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -55,13 +55,13 @@ module ActiveRecord owner = Owner.create!(name: "hello".encode("ascii-8bit")) owner.reload select = Owner.columns.map { |c| "typeof(#{c.name})" }.join ", " - result = Owner.connection.exec_query <<-esql + result = Owner.connection.exec_query <<~SQL SELECT #{select} FROM #{Owner.table_name} WHERE #{Owner.primary_key} = #{owner.id} - esql + SQL - 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 @@ -87,7 +87,7 @@ module ActiveRecord def test_connection_no_db assert_raises(ArgumentError) do - Base.sqlite3_connection {} + Base.sqlite3_connection { } end end @@ -160,14 +160,14 @@ module ActiveRecord end def test_quote_binary_column_escapes_it - DualEncoding.connection.execute(<<-eosql) + DualEncoding.connection.execute(<<~SQL) CREATE TABLE IF NOT EXISTS dual_encodings ( id integer PRIMARY KEY AUTOINCREMENT, name varchar(255), data binary ) - eosql - str = "\x80".dup.force_encoding("ASCII-8BIT") + SQL + str = (+"\x80").force_encoding("ASCII-8BIT") binary = DualEncoding.new name: "いただきます!", data: str binary.save! assert_equal str, binary.data @@ -176,7 +176,7 @@ module ActiveRecord end def test_type_cast_should_not_mutate_encoding - name = "hello".dup.force_encoding(Encoding::ASCII_8BIT) + name = (+"hello").force_encoding(Encoding::ASCII_8BIT) Owner.create(name: name) assert_equal Encoding::ASCII_8BIT, name.encoding ensure @@ -261,7 +261,7 @@ module ActiveRecord end def test_tables_logs_name - sql = <<-SQL + sql = <<~SQL SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND type IN ('table') SQL assert_logged [[sql.squish, "SCHEMA", []]] do @@ -271,7 +271,7 @@ module ActiveRecord def test_table_exists_logs_name with_example_table do - sql = <<-SQL + sql = <<~SQL SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND name = 'ex' AND type IN ('table') SQL assert_logged [[sql.squish, "SCHEMA", []]] do @@ -345,6 +345,42 @@ module ActiveRecord end end + if ActiveRecord::Base.connection.supports_expression_index? + def test_expression_index + with_example_table do + @conn.add_index "ex", "max(id, number)", name: "expression" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "max(id, number)", index.columns + end + end + + def test_expression_index_with_where + with_example_table do + @conn.add_index "ex", "id % 10, max(id, number)", name: "expression", where: "id > 1000" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "id % 10, max(id, number)", index.columns + assert_equal "id > 1000", index.where + end + end + + def test_complicated_expression + with_example_table do + @conn.execute "CREATE INDEX expression ON ex (id % 10, (CASE WHEN number > 0 THEN max(id, number) END))WHERE(id > 1000)" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "id % 10, (CASE WHEN number > 0 THEN max(id, number) END)", index.columns + assert_equal "(id > 1000)", index.where + end + end + + def test_not_everything_an_expression + with_example_table do + @conn.add_index "ex", "id, max(id, number)", name: "expression" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "id, max(id, number)", index.columns + end + end + end + def test_primary_key with_example_table do assert_equal "id", @conn.primary_key("ex") @@ -500,8 +536,103 @@ module ActiveRecord end end - def test_deprecate_valid_alter_table_type - assert_deprecated { @conn.valid_alter_table_type?(:string) } + 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 + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + end + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'") + end + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("DELETE FROM ex where data = '138853948594'") + end + end + end + end + + def test_errors_when_a_replace_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("REPLACE INTO ex (data) VALUES ('249823948')") + end + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute("SELECT data from ex WHERE data = '138853948594'").count + end + end + end + + def test_doesnt_error_when_a_read_query_with_leading_chars_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute(" SELECT data from ex WHERE data = '138853948594'").count + end + end end private @@ -516,7 +647,7 @@ module ActiveRecord end def with_example_table(definition = nil, table_name = "ex", &block) - definition ||= <<-SQL + definition ||= <<~SQL id integer PRIMARY KEY AUTOINCREMENT, number integer SQL 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 d70486605f..cfc9853aba 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb @@ -8,17 +8,15 @@ module ActiveRecord class SQLite3CreateFolder < ActiveRecord::SQLite3TestCase def test_sqlite_creates_directory Dir.mktmpdir do |dir| - begin - dir = Pathname.new(dir) - @conn = Base.sqlite3_connection database: dir.join("db/foo.sqlite3"), - adapter: "sqlite3", - timeout: 100 + dir = Pathname.new(dir) + @conn = Base.sqlite3_connection database: dir.join("db/foo.sqlite3"), + adapter: "sqlite3", + timeout: 100 - assert Dir.exist? dir.join("db") - assert File.exist? dir.join("db/foo.sqlite3") - ensure - @conn.disconnect! if @conn - end + assert Dir.exist? dir.join("db") + assert File.exist? dir.join("db/foo.sqlite3") + ensure + @conn.disconnect! if @conn end end end diff --git a/activerecord/test/cases/aggregations_test.rb b/activerecord/test/cases/aggregations_test.rb index fbdf2ada4b..d270175af4 100644 --- a/activerecord/test/cases/aggregations_test.rb +++ b/activerecord/test/cases/aggregations_test.rb @@ -27,7 +27,7 @@ class AggregationsTest < ActiveRecord::TestCase def test_immutable_value_objects customers(:david).balance = Money.new(100) - assert_raise(frozen_error_class) { customers(:david).balance.instance_eval { @amount = 20 } } + assert_raise(FrozenError) { 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 140d7cbcae..9027cc582a 100644 --- a/activerecord/test/cases/ar_schema_test.rb +++ b/activerecord/test/cases/ar_schema_test.rb @@ -51,11 +51,11 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase assert_equal 7, @connection.migration_context.current_version end - def test_schema_define_w_table_name_prefix - table_name = ActiveRecord::SchemaMigration.table_name + def test_schema_define_with_table_name_prefix old_table_name_prefix = ActiveRecord::Base.table_name_prefix ActiveRecord::Base.table_name_prefix = "nep_" - ActiveRecord::SchemaMigration.table_name = "nep_#{table_name}" + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name ActiveRecord::Schema.define(version: 7) do create_table :fruits do |t| t.column :color, :string @@ -67,7 +67,8 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase assert_equal 7, @connection.migration_context.current_version ensure ActiveRecord::Base.table_name_prefix = old_table_name_prefix - ActiveRecord::SchemaMigration.table_name = table_name + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name end def test_schema_raises_an_error_for_invalid_column_type @@ -116,8 +117,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 @connection.column_exists?(:has_timestamps, :created_at, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) end def test_timestamps_without_null_set_null_to_false_on_change_table @@ -129,8 +130,23 @@ 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 @connection.column_exists?(:has_timestamps, :created_at, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) + end + + if ActiveRecord::Base.connection.supports_bulk_alter? + def test_timestamps_without_null_set_null_to_false_on_change_table_with_bulk + ActiveRecord::Schema.define do + create_table :has_timestamps + + change_table :has_timestamps, bulk: true do |t| + t.timestamps default: Time.now + end + end + + assert @connection.column_exists?(:has_timestamps, :created_at, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) + end end def test_timestamps_without_null_set_null_to_false_on_add_timestamps @@ -139,7 +155,58 @@ 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 @connection.column_exists?(:has_timestamps, :created_at, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) + end + + if subsecond_precision_supported? + def test_timestamps_sets_presicion_on_create_table + ActiveRecord::Schema.define do + create_table :has_timestamps do |t| + t.timestamps + end + end + + assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) + end + + def test_timestamps_sets_presicion_on_change_table + ActiveRecord::Schema.define do + create_table :has_timestamps + + change_table :has_timestamps do |t| + t.timestamps default: Time.now + end + end + + assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) + end + + if ActiveRecord::Base.connection.supports_bulk_alter? + def test_timestamps_sets_presicion_on_change_table_with_bulk + ActiveRecord::Schema.define do + create_table :has_timestamps + + change_table :has_timestamps, bulk: true do |t| + t.timestamps default: Time.now + end + end + + assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) + end + end + + def test_timestamps_sets_presicion_on_add_timestamps + ActiveRecord::Schema.define do + create_table :has_timestamps + add_timestamps :has_timestamps, default: Time.now + end + + assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) + assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) + end 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..c7bd0a053b --- /dev/null +++ b/activerecord/test/cases/arel/attributes/attribute_test.rb @@ -0,0 +1,1080 @@ +# 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 "#between" 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 + + if Gem::Version.new("2.6.0") <= Gem::Version.new(RUBY_VERSION) + it "can be constructed with a range implicitly ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(eval("0..")) # Use eval for compatibility with Ruby < 2.6 parser + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + 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 + 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 "#not_between" 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 a quoted range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(-::Float::INFINITY, 3, false)) + + node.must_equal Nodes::GreaterThan.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.not_between(-::Float::INFINITY...3) + + node.must_equal Nodes::GreaterThanOrEqual.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.not_between(quoted_range(-::Float::INFINITY, 3, true)) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Quoted.new(3) + ) + 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 quoted infinite range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(-::Float::INFINITY, ::Float::INFINITY, false)) + + 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 + + if Gem::Version.new("2.6.0") <= Gem::Version.new(RUBY_VERSION) + it "can be constructed with a range implicitly ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(eval("0..")) # Use eval for compatibility with Ruby < 2.6 parser + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + end + + it "can be constructed with a quoted range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(0, ::Float::INFINITY, false)) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Quoted.new(0) + ) + 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 + + private + 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 + 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..443c7eb54b --- /dev/null +++ b/activerecord/test/cases/arel/collectors/sql_string_test.rb @@ -0,0 +1,41 @@ +# 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 + 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 test_compile + sql = compile(ast_with_binds) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + end + + def test_returned_sql_uses_utf8_encoding + sql = compile(ast_with_binds) + 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..0bad02f4d2 --- /dev/null +++ b/activerecord/test/cases/arel/delete_manager_test.rb @@ -0,0 +1,53 @@ +# 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 + dm.key = table[:id] + 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..79b85742ee --- /dev/null +++ b/activerecord/test/cases/arel/insert_manager_test.rb @@ -0,0 +1,241 @@ +# 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 ValuesList node" do + manager = Arel::InsertManager.new + values = manager.create_values_list([%w{ a b }, %w{ c d }]) + + assert_kind_of Arel::Nodes::ValuesList, values + assert_equal [%w{ a b }, %w{ c d }], values.rows + end + + it "allows sql literals" do + manager = Arel::InsertManager.new + manager.into Table.new(:users) + manager.values = manager.create_values([Arel.sql("*")]) + 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::ValuesList.new([[1], [2]]) + manager.to_sql.must_be_like %{ + INSERT INTO "users" VALUES (1), (2) + } + 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::ValuesList.new([[1, "aaron"], [2, "david"]]) + manager.columns << table[:id] + manager.columns << table[:name] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron'), (2, 'david') + } + 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..d123ca9fd0 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/and_test.rb @@ -0,0 +1,30 @@ +# 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 + + describe "functions as node expression" do + it "allows aliasing" do + aliased = And.new(["foo", "bar"]).as("baz") + + assert_kind_of As, aliased + assert_kind_of SqlLiteral, aliased.right + 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..946c2b0453 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/case_test.rb @@ -0,0 +1,96 @@ +# 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 + + describe "#as" do + it "allows aliasing" do + node = Case.new "foo" + as = node.as("bar") + + assert_equal node, as.left + assert_kind_of Arel::Nodes::SqlLiteral, as.right + 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/comment_test.rb b/activerecord/test/cases/arel/nodes/comment_test.rb new file mode 100644 index 0000000000..bf5eaf4c5a --- /dev/null +++ b/activerecord/test/cases/arel/nodes/comment_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "yaml" + +module Arel + module Nodes + class CommentTest < Arel::Spec + describe "equality" do + it "is equal with equal contents" do + array = [Comment.new(["foo"]), Comment.new(["foo"])] + assert_equal 1, array.uniq.size + end + + it "is not equal with different contents" do + array = [Comment.new(["foo"]), Comment.new(["bar"])] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/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..6860f2a395 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/select_core_test.rb @@ -0,0 +1,79 @@ +# 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] + core1.comment = Arel::Nodes::Comment.new(["comment"]) + 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] + core2.comment = Arel::Nodes::Comment.new(["comment"]) + 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] + core1.comment = Arel::Nodes::Comment.new(["comment"]) + 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] + core2.comment = Arel::Nodes::Comment.new(["comment"]) + array = [core1, core2] + assert_equal 2, array.uniq.size + core2.havings = %w[p q r] + core2.comment = Arel::Nodes::Comment.new(["other"]) + array = [core1, core2] + assert_equal 2, array.uniq.size + end + 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..e6c49cd429 --- /dev/null +++ b/activerecord/test/cases/arel/select_manager_test.rb @@ -0,0 +1,1248 @@ +# 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 + + describe "comment" do + it "chains" do + manager = Arel::SelectManager.new + manager.comment("selecting").must_equal manager + end + + it "appends a comment to the generated query" do + manager = Arel::SelectManager.new + table = Table.new :users + manager.from(table).project(table["id"]) + + manager.comment("selecting") + manager.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" /* selecting */ + } + + manager.comment("selecting", "with", "comment") + manager.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" /* selecting */ /* with */ /* comment */ + } + end + end + end +end diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb new file mode 100644 index 0000000000..18e6c10c9d --- /dev/null +++ b/activerecord/test/cases/arel/support/fake_record.rb @@ -0,0 +1,133 @@ +# 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 sanitize_as_sql_comment(comment) + comment + 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..106be2311d --- /dev/null +++ b/activerecord/test/cases/arel/visitors/depth_first_test.rb @@ -0,0 +1,276 @@ +# 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::ValuesList, + 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 + + def test_comment + comment = Nodes::Comment.new ["foo"] + @visitor.accept comment + assert_equal ["foo", ["foo"], comment], @collector.calls + end + + [ + Arel::Nodes::Assignment, + Arel::Nodes::Between, + 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::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..ade53c358e --- /dev/null +++ b/activerecord/test/cases/arel/visitors/dot_test.rb @@ -0,0 +1,83 @@ +# 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::ValuesList, + 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::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..2ddbec3266 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/ibm_db_test.rb @@ -0,0 +1,73 @@ +# 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + 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..b6c2dd6ae7 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/informix_test.rb @@ -0,0 +1,98 @@ +# 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + CASE WHEN "users"."name" = 'Aaron Patterson' OR ("users"."name" IS NULL AND 'Aaron Patterson' IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + 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..74f34b4dad --- /dev/null +++ b/activerecord/test/cases/arel/visitors/mssql_test.rb @@ -0,0 +1,138 @@ +# 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + EXISTS (VALUES ("users"."name") INTERSECT VALUES ('Aaron Patterson')) + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + EXISTS (VALUES ("users"."first_name") INTERSECT VALUES ("users"."last_name")) + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + NOT EXISTS (VALUES ("users"."first_name") INTERSECT VALUES ("users"."last_name")) + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + 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..5f37587957 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/mysql_test.rb @@ -0,0 +1,109 @@ +# 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 + + ### + # :'( + # 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" <=> 'Aaron Patterson' + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" <=> "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" <=> NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + NOT "users"."first_name" <=> "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ NOT "users"."name" <=> NULL } + 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..ebea12910d --- /dev/null +++ b/activerecord/test/cases/arel/visitors/oracle12_test.rb @@ -0,0 +1,121 @@ +# 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) + @attr = @table[:id] + 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + + describe "Nodes::In" do + it "should know how to visit" do + ary = (1 .. 1001).to_a + node = @attr.in ary + compile(node).must_be_like %{ + "users"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 581, 582, 583, 584, 585, 586, 587, 588, 589, 590, 591, 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702, 703, 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, 715, 716, 717, 718, 719, 720, 721, 722, 723, 724, 725, 726, 727, 728, 729, 730, 731, 732, 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771, 772, 773, 774, 775, 776, 777, 778, 779, 780, 781, 782, 783, 784, 785, 786, 787, 788, 789, 790, 791, 792, 793, 794, 795, 796, 797, 798, 799, 800, 801, 802, 803, 804, 805, 806, 807, 808, 809, 810, 811, 812, 813, 814, 815, 816, 817, 818, 819, 820, 821, 822, 823, 824, 825, 826, 827, 828, 829, 830, 831, 832, 833, 834, 835, 836, 837, 838, 839, 840, 841, 842, 843, 844, 845, 846, 847, 848, 849, 850, 851, 852, 853, 854, 855, 856, 857, 858, 859, 860, 861, 862, 863, 864, 865, 866, 867, 868, 869, 870, 871, 872, 873, 874, 875, 876, 877, 878, 879, 880, 881, 882, 883, 884, 885, 886, 887, 888, 889, 890, 891, 892, 893, 894, 895, 896, 897, 898, 899, 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 911, 912, 913, 914, 915, 916, 917, 918, 919, 920, 921, 922, 923, 924, 925, 926, 927, 928, 929, 930, 931, 932, 933, 934, 935, 936, 937, 938, 939, 940, 941, 942, 943, 944, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 962, 963, 964, 965, 966, 967, 968, 969, 970, 971, 972, 973, 974, 975, 976, 977, 978, 979, 980, 981, 982, 983, 984, 985, 986, 987, 988, 989, 990, 991, 992, 993, 994, 995, 996, 997, 998, 999, 1000) OR \"users\".\"id\" IN (1001) + } + end + end + + describe "Nodes::NotIn" do + it "should know how to visit" do + ary = (1 .. 1001).to_a + node = @attr.not_in ary + compile(node).must_be_like %{ + "users"."id" NOT IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 581, 582, 583, 584, 585, 586, 587, 588, 589, 590, 591, 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702, 703, 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, 715, 716, 717, 718, 719, 720, 721, 722, 723, 724, 725, 726, 727, 728, 729, 730, 731, 732, 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771, 772, 773, 774, 775, 776, 777, 778, 779, 780, 781, 782, 783, 784, 785, 786, 787, 788, 789, 790, 791, 792, 793, 794, 795, 796, 797, 798, 799, 800, 801, 802, 803, 804, 805, 806, 807, 808, 809, 810, 811, 812, 813, 814, 815, 816, 817, 818, 819, 820, 821, 822, 823, 824, 825, 826, 827, 828, 829, 830, 831, 832, 833, 834, 835, 836, 837, 838, 839, 840, 841, 842, 843, 844, 845, 846, 847, 848, 849, 850, 851, 852, 853, 854, 855, 856, 857, 858, 859, 860, 861, 862, 863, 864, 865, 866, 867, 868, 869, 870, 871, 872, 873, 874, 875, 876, 877, 878, 879, 880, 881, 882, 883, 884, 885, 886, 887, 888, 889, 890, 891, 892, 893, 894, 895, 896, 897, 898, 899, 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 911, 912, 913, 914, 915, 916, 917, 918, 919, 920, 921, 922, 923, 924, 925, 926, 927, 928, 929, 930, 931, 932, 933, 934, 935, 936, 937, 938, 939, 940, 941, 942, 943, 944, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 962, 963, 964, 965, 966, 967, 968, 969, 970, 971, 972, 973, 974, 975, 976, 977, 978, 979, 980, 981, 982, 983, 984, 985, 986, 987, 988, 989, 990, 991, 992, 993, 994, 995, 996, 997, 998, 999, 1000) AND \"users\".\"id\" NOT IN (1001) + } + 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..f69b201855 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/oracle_test.rb @@ -0,0 +1,257 @@ +# 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) + @attr = @table[:id] + 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + + describe "Nodes::In" do + it "should know how to visit" do + ary = (1 .. 1001).to_a + node = @attr.in ary + compile(node).must_be_like %{ + "users"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 581, 582, 583, 584, 585, 586, 587, 588, 589, 590, 591, 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702, 703, 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, 715, 716, 717, 718, 719, 720, 721, 722, 723, 724, 725, 726, 727, 728, 729, 730, 731, 732, 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771, 772, 773, 774, 775, 776, 777, 778, 779, 780, 781, 782, 783, 784, 785, 786, 787, 788, 789, 790, 791, 792, 793, 794, 795, 796, 797, 798, 799, 800, 801, 802, 803, 804, 805, 806, 807, 808, 809, 810, 811, 812, 813, 814, 815, 816, 817, 818, 819, 820, 821, 822, 823, 824, 825, 826, 827, 828, 829, 830, 831, 832, 833, 834, 835, 836, 837, 838, 839, 840, 841, 842, 843, 844, 845, 846, 847, 848, 849, 850, 851, 852, 853, 854, 855, 856, 857, 858, 859, 860, 861, 862, 863, 864, 865, 866, 867, 868, 869, 870, 871, 872, 873, 874, 875, 876, 877, 878, 879, 880, 881, 882, 883, 884, 885, 886, 887, 888, 889, 890, 891, 892, 893, 894, 895, 896, 897, 898, 899, 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 911, 912, 913, 914, 915, 916, 917, 918, 919, 920, 921, 922, 923, 924, 925, 926, 927, 928, 929, 930, 931, 932, 933, 934, 935, 936, 937, 938, 939, 940, 941, 942, 943, 944, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 962, 963, 964, 965, 966, 967, 968, 969, 970, 971, 972, 973, 974, 975, 976, 977, 978, 979, 980, 981, 982, 983, 984, 985, 986, 987, 988, 989, 990, 991, 992, 993, 994, 995, 996, 997, 998, 999, 1000) OR \"users\".\"id\" IN (1001) + } + end + end + + describe "Nodes::NotIn" do + it "should know how to visit" do + ary = (1 .. 1001).to_a + node = @attr.not_in ary + compile(node).must_be_like %{ + "users"."id" NOT IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 581, 582, 583, 584, 585, 586, 587, 588, 589, 590, 591, 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702, 703, 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, 715, 716, 717, 718, 719, 720, 721, 722, 723, 724, 725, 726, 727, 728, 729, 730, 731, 732, 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771, 772, 773, 774, 775, 776, 777, 778, 779, 780, 781, 782, 783, 784, 785, 786, 787, 788, 789, 790, 791, 792, 793, 794, 795, 796, 797, 798, 799, 800, 801, 802, 803, 804, 805, 806, 807, 808, 809, 810, 811, 812, 813, 814, 815, 816, 817, 818, 819, 820, 821, 822, 823, 824, 825, 826, 827, 828, 829, 830, 831, 832, 833, 834, 835, 836, 837, 838, 839, 840, 841, 842, 843, 844, 845, 846, 847, 848, 849, 850, 851, 852, 853, 854, 855, 856, 857, 858, 859, 860, 861, 862, 863, 864, 865, 866, 867, 868, 869, 870, 871, 872, 873, 874, 875, 876, 877, 878, 879, 880, 881, 882, 883, 884, 885, 886, 887, 888, 889, 890, 891, 892, 893, 894, 895, 896, 897, 898, 899, 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 911, 912, 913, 914, 915, 916, 917, 918, 919, 920, 921, 922, 923, 924, 925, 926, 927, 928, 929, 930, 931, 932, 933, 934, 935, 936, 937, 938, 939, 940, 941, 942, 943, 944, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 962, 963, 964, 965, 966, 967, 968, 969, 970, 971, 972, 973, 974, 975, 976, 977, 978, 979, 980, 981, 982, 983, 984, 985, 986, 987, 988, 989, 990, 991, 992, 993, 994, 995, 996, 997, 998, 999, 1000) AND \"users\".\"id\" NOT IN (1001) + } + 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..0f9efb20b4 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/postgres_test.rb @@ -0,0 +1,320 @@ +# 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" IS NOT DISTINCT FROM 'Aaron Patterson' + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS NOT DISTINCT FROM "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT DISTINCT FROM NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS DISTINCT FROM "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS DISTINCT FROM NULL } + 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..ee4e07a675 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/sqlite_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class SqliteTest < Arel::Spec + before do + @visitor = SQLite.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + 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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" IS 'Aaron Patterson' + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS NOT "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + 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..625e37f1c0 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/to_sql_test.rb @@ -0,0 +1,713 @@ +# 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 ValuesList" do + bp = Nodes::BindParam.new(1) + values = Nodes::ValuesList.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 + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + CASE WHEN "users"."name" = 'Aaron Patterson' OR ("users"."name" IS NULL AND 'Aaron Patterson' IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle nil" do + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 1 + } + end + + it "should handle nil" do + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.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_Integer" 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::Union" do + 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("( left UNION right UNION topright )", compile(node)) + subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right") + node = Nodes::Union.new Arel.sql("topleft"), subnode + assert_equal("( topleft UNION left UNION right )", compile(node)) + end + end + + describe "Nodes::UnionAll" do + it "squashes parenthesis on multiple union alls" do + subnode = Nodes::UnionAll.new Arel.sql("left"), Arel.sql("right") + node = Nodes::UnionAll.new subnode, Arel.sql("topright") + assert_equal("( left UNION ALL right UNION ALL topright )", compile(node)) + subnode = Nodes::UnionAll.new Arel.sql("left"), Arel.sql("right") + node = Nodes::UnionAll.new Arel.sql("topleft"), subnode + assert_equal("( topleft UNION ALL left UNION ALL right )", compile(node)) + 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/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index 6b4f826766..3525fa2ab8 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -32,9 +32,27 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase :posts, :tags, :taggings, :comments, :sponsors, :members def test_belongs_to - firm = Client.find(3).firm - assert_not_nil firm - assert_equal companies(:first_firm).name, firm.name + client = Client.find(3) + first_firm = companies(:first_firm) + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + assert_equal first_firm, client.firm + assert_equal first_firm.name, client.firm.name + end + end + + def test_assigning_belongs_to_on_destroyed_object + client = Client.create!(name: "Client") + client.destroy! + assert_raise(FrozenError) { client.firm = nil } + assert_raise(FrozenError) { 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 @@ -45,7 +63,8 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase ActiveRecord::SQLCounter.clear_log Client.find(3).firm ensure - assert ActiveRecord::SQLCounter.log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query" + sql_log = ActiveRecord::SQLCounter.log + assert sql_log.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query: #{sql_log}" end def test_belongs_to_with_primary_key @@ -265,6 +284,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") @@ -343,6 +371,30 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal "ODEGY", odegy_account.reload_firm.name end + def test_reload_the_belonging_object_with_query_cache + odegy_account_id = accounts(:odegy_account).id + + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + # Populate the cache with a query + odegy_account = Account.find(odegy_account_id) + + # Populate the cache with a second query + odegy_account.firm + + assert_equal 2, connection.query_cache.size + + # Clear the cache and fetch the firm again, populating the cache with a query + assert_queries(1) { odegy_account.reload_firm } + + # This query is not cached anymore, so it should make a real SQL query + assert_queries(1) { Account.find(odegy_account_id) } + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + def test_natural_assignment_to_nil client = Client.find(3) client.firm = nil @@ -396,8 +448,13 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end def test_with_select - assert_equal 1, Company.find(2).firm_with_select.attributes.size - assert_equal 1, Company.all.merge!(includes: :firm_with_select).find(2).firm_with_select.attributes.size + assert_equal 1, Post.find(2).author_with_select.attributes.size + assert_equal 1, Post.includes(:author_with_select).find(2).author_with_select.attributes.size + end + + def test_custom_attribute_with_select + assert_equal 2, Company.find(2).firm_with_select.attributes.size + assert_equal 2, Company.includes(:firm_with_select).find(2).firm_with_select.attributes.size end def test_belongs_to_without_counter_cache_option @@ -428,31 +485,70 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end def test_belongs_to_counter_with_assigning_nil - post = Post.find(1) - comment = Comment.find(1) + topic = Topic.create!(title: "debate") + reply = Reply.create!(title: "blah!", content: "world around!", topic: topic) + + assert_equal topic.id, reply.parent_id + assert_equal 1, topic.reload.replies.size + + reply.topic = nil + reply.reload - assert_equal post.id, comment.post_id - assert_equal 2, Post.find(post.id).comments.size + assert_equal topic.id, reply.parent_id + assert_equal 1, topic.reload.replies.size - comment.post = nil + reply.topic = nil + reply.save! - assert_equal 1, Post.find(post.id).comments.size + assert_equal 0, topic.reload.replies.size + end + + def test_belongs_to_counter_with_assigning_new_object + topic = Topic.create!(title: "debate") + reply = Reply.create!(title: "blah!", content: "world around!", topic: topic) + + assert_equal topic.id, reply.parent_id + assert_equal 1, topic.reload.replies_count + + topic2 = reply.build_topic(title: "debate2") + reply.save! + + assert_not_equal topic.id, reply.parent_id + assert_equal topic2.id, reply.parent_id + + assert_equal 0, topic.reload.replies_count + assert_equal 1, topic2.reload.replies_count end 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 reply.topic_with_primary_key = debate2 + reply.save! assert_equal 0, debate.reload.replies_count assert_equal 1, debate2.reload.replies_count reply.topic_with_primary_key = nil + reply.save! assert_equal 0, debate.reload.replies_count assert_equal 0, debate2.reload.replies_count @@ -479,11 +575,13 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal 1, Topic.find(topic2.id).replies.size reply1.topic = nil + reply1.save! assert_equal 0, Topic.find(topic1.id).replies.size assert_equal 0, Topic.find(topic2.id).replies.size reply1.topic = topic1 + reply1.save! assert_equal 1, Topic.find(topic1.id).replies.size assert_equal 0, Topic.find(topic2.id).replies.size @@ -513,13 +611,64 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase def test_belongs_to_counter_after_save topic = Topic.create!(title: "monday night") - topic.replies.create!(title: "re: monday night", content: "football") + + assert_queries(2) do + topic.replies.create!(title: "re: monday night", content: "football") + end + assert_equal 1, Topic.find(topic.id)[:replies_count] topic.save! 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) + + assert_queries(3) do + reply.parent_title = "debate" + reply.save! + end + + assert_operator debate.reload.updated_at, :>, time + assert_operator debate2.reload.updated_at, :>, time + + debate.touch(time: time) + debate2.touch(time: time) + + assert_queries(3) do + reply.topic_with_primary_key = debate2 + reply.save! + end + + 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]) @@ -578,7 +727,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase line_item = LineItem.create! Invoice.create!(line_items: [line_item]) - assert_queries(0) { line_item.save } + assert_no_queries { line_item.save } end def test_belongs_to_with_touch_option_on_destroy @@ -673,7 +822,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase def test_dont_find_target_when_foreign_key_is_null tagging = taggings(:thinking_general) - assert_queries(0) { tagging.super_tag } + assert_no_queries { tagging.super_tag } end def test_dont_find_target_when_saving_foreign_key_after_stale_association_loaded @@ -693,6 +842,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase reply = Reply.create(title: "re: zoom", content: "speedy quick!") reply.topic = topic + reply.save! assert_equal 1, topic.reload[:replies_count] assert_equal 1, topic.replies.size @@ -748,6 +898,7 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase silly = SillyReply.create(title: "gaga", content: "boo-boo") silly.reply = reply + silly.save! assert_equal 1, reply.reload[:replies_count] assert_equal 1, reply.replies.size @@ -1034,9 +1185,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 @@ -1137,17 +1299,17 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end def test_belongs_to_with_out_of_range_value_assigning - model = Class.new(Comment) do + model = Class.new(Author) do def self.name; "Temp"; end - validates :post, presence: true + validates :author_address, presence: true end - comment = model.new - comment.post_id = 9223372036854775808 # out of range in the bigint + author = model.new + author.author_address_id = 9223372036854775808 # out of range in the bigint - assert_nil comment.post - assert_not_predicate comment, :valid? - assert_equal [{ error: :blank }], comment.errors.details[:post] + assert_nil author.author_address + assert_not_predicate author, :valid? + assert_equal [{ error: :blank }], author.errors.details[:author_address] end def test_polymorphic_with_custom_primary_key diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index e717621928..49f754be63 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -18,7 +18,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase :categorizations, :people, :categories, :edges, :vertices def test_eager_association_loading_with_cascaded_two_levels - authors = Author.all.merge!(includes: { posts: :comments }, order: "authors.id").to_a + authors = Author.includes(posts: :comments).order(:id).to_a assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 3, authors[1].posts.size @@ -26,7 +26,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_cascaded_two_levels_and_one_level - authors = Author.all.merge!(includes: [{ posts: :comments }, :categorizations], order: "authors.id").to_a + authors = Author.includes({ posts: :comments }, :categorizations).order(:id).to_a assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 3, authors[1].posts.size @@ -36,7 +36,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_hmt_does_not_table_name_collide_when_joining_associations - authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a + authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).order(:id).to_a assert_equal 3, assert_no_queries { authors.size } assert_equal 10, assert_no_queries { authors[0].comments.size } end @@ -117,12 +117,11 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end def test_eager_association_loading_with_has_many_sti_and_subclasses - silly = SillyReply.new(title: "gaga", content: "boo-boo", parent_id: 1) - silly.parent_id = 1 - assert silly.save + reply = Reply.new(title: "gaga", content: "boo-boo", parent_id: 1) + assert reply.save topics = Topic.all.merge!(includes: :replies, order: ["topics.id", "replies_topics.id"]).to_a - assert_no_queries do + assert_queries(0) do assert_equal 2, topics[0].replies.size assert_equal 0, topics[1].replies.size end @@ -161,6 +160,16 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end end + def test_preload_through_missing_records + post = Post.where.not(author_id: Author.select(:id)).preload(author: { comments: :post }).first! + assert_no_queries { assert_nil post.author } + end + + def test_eager_association_loading_with_missing_first_record + posts = Post.where(id: 3).preload(author: { comments: :post }).to_a + assert_equal posts.size, 1 + end + def test_eager_association_loading_with_recursive_cascading_four_levels_has_many_through source = Vertex.all.merge!(includes: { sinks: { sinks: { sinks: :sinks } } }, order: "vertices.id").first assert_equal vertices(:vertex_4), assert_no_queries { source.sinks.first.sinks.first.sinks.first } diff --git a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb index 5fca972aee..673d5f1dcf 100644 --- a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb +++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb @@ -21,7 +21,7 @@ module PolymorphicFullStiClassNamesSharedTest ActiveRecord::Base.store_full_sti_class = store_full_sti_class post = Namespaced::Post.create(title: "Great stuff", body: "This is not", author_id: 1) - @tagging = Tagging.create(taggable: post) + @tagging = post.create_tagging! end def teardown diff --git a/activerecord/test/cases/associations/eager_load_nested_include_test.rb b/activerecord/test/cases/associations/eager_load_nested_include_test.rb index c5b2b77bd4..525ad3197a 100644 --- a/activerecord/test/cases/associations/eager_load_nested_include_test.rb +++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb @@ -92,7 +92,7 @@ class EagerLoadPolyAssocsTest < ActiveRecord::TestCase def test_include_query res = ShapeExpression.all.merge!(includes: [ :shape, { paint: :non_poly } ]).to_a assert_equal NUM_SHAPE_EXPRESSIONS, res.size - assert_queries(0) do + assert_no_queries do res.each do |se| assert_not_nil se.paint.non_poly, "this is the association that was loading incorrectly before the change" assert_not_nil se.shape, "just making sure other associations still work" diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index 6e0cf30092..594d161fa3 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -4,6 +4,7 @@ require "cases/helper" require "models/post" require "models/tagging" require "models/tag" +require "models/rating" require "models/comment" require "models/author" require "models/essay" @@ -18,6 +19,7 @@ require "models/job" require "models/subscriber" require "models/subscription" require "models/book" +require "models/citation" require "models/developer" require "models/computer" require "models/project" @@ -29,9 +31,21 @@ require "models/sponsor" require "models/mentor" require "models/contract" +class EagerLoadingTooManyIdsTest < ActiveRecord::TestCase + fixtures :citations + + def test_preloading_too_many_ids + assert_equal Citation.count, Citation.preload(:reference_of).to_a.size + end + + def test_eager_loading_too_may_ids + assert_equal Citation.count, Citation.eager_load(:citations).offset(0).size + end +end + class EagerAssociationTest < ActiveRecord::TestCase fixtures :posts, :comments, :authors, :essays, :author_addresses, :categories, :categories_posts, - :companies, :accounts, :tags, :taggings, :people, :readers, :categorizations, + :companies, :accounts, :tags, :taggings, :ratings, :people, :readers, :categorizations, :owners, :pets, :author_favorites, :jobs, :references, :subscribers, :subscriptions, :books, :developers, :projects, :developers_projects, :members, :memberships, :clubs, :sponsors @@ -76,9 +90,80 @@ class EagerAssociationTest < ActiveRecord::TestCase "expected to find only david's posts" end + def test_loading_polymorphic_association_with_mixed_table_conditions + rating = Rating.first + assert_equal [taggings(:normal_comment_rating)], rating.taggings_without_tag + + rating = Rating.preload(:taggings_without_tag).first + assert_equal [taggings(:normal_comment_rating)], rating.taggings_without_tag + + rating = Rating.eager_load(:taggings_without_tag).first + assert_equal [taggings(:normal_comment_rating)], rating.taggings_without_tag + end + def test_loading_with_scope_including_joins - assert_equal clubs(:boring_club), Member.preload(:general_club).find(1).general_club - assert_equal clubs(:boring_club), Member.eager_load(:general_club).find(1).general_club + 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 @@ -1186,12 +1271,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_include_has_many_using_primary_key expected = Firm.find(1).clients_using_primary_key.sort_by(&:name) - # Oracle adapter truncates alias to 30 characters - if current_adapter?(:OracleAdapter) - firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies"[0, 30] + ".name").find(1) - else - firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies.name").find(1) - end + firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies.name").find(1) assert_no_queries do assert_equal expected, firm.clients_using_primary_key end @@ -1273,7 +1353,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_joins_with_includes_should_preload_via_joins post = assert_queries(1) { Post.includes(:comments).joins(:comments).order("posts.id desc").to_a.first } - assert_queries(0) do + assert_no_queries do assert_not_equal 0, post.comments.to_a.count end end @@ -1320,11 +1400,24 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal expected, FirstPost.unscoped.find(2) end - test "preload ignores the scoping" do - assert_equal( - Comment.find(1).post, - Post.where("1 = 0").scoping { Comment.preload(:post).find(1).post } - ) + test "belongs_to association ignores the scoping" do + post = Comment.find(1).post + + Post.where("1=0").scoping do + assert_equal post, Comment.find(1).post + assert_equal post, Comment.preload(:post).find(1).post + assert_equal post, Comment.eager_load(:post).find(1).post + end + end + + test "has_many association ignores the scoping" do + comments = Post.find(1).comments.to_a + + Comment.where("1=0").scoping do + assert_equal comments, Post.find(1).comments + assert_equal comments, Post.preload(:comments).find(1).comments + assert_equal comments, Post.eager_load(:comments).find(1).comments + end end test "deep preload" do @@ -1511,8 +1604,38 @@ class EagerAssociationTest < ActiveRecord::TestCase # 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 diff --git a/activerecord/test/cases/associations/extension_test.rb b/activerecord/test/cases/associations/extension_test.rb index 5eacb5a3d8..aef8f31112 100644 --- a/activerecord/test/cases/associations/extension_test.rb +++ b/activerecord/test/cases/associations/extension_test.rb @@ -89,6 +89,6 @@ class AssociationsExtensionsTest < ActiveRecord::TestCase private def extend!(model) - ActiveRecord::Associations::Builder::HasMany.define_extensions(model, :association_name) {} + ActiveRecord::Associations::Builder::HasMany.define_extensions(model, :association_name) { } end end 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 5f771fe85f..fe8bdd03ba 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 @@ -25,6 +25,8 @@ require "models/user" require "models/member" require "models/membership" require "models/sponsor" +require "models/lesson" +require "models/student" require "models/country" require "models/treaty" require "models/vertex" @@ -275,7 +277,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_habtm_saving_multiple_relationships new_project = Project.new("name" => "Grimetime") amount_of_developers = 4 - developers = (0...amount_of_developers).collect { |i| Developer.create(name: "JME #{i}") }.reverse + developers = (0...amount_of_developers).reverse_each.map { |i| Developer.create(name: "JME #{i}") } new_project.developer_ids = [developers[0].id, developers[1].id] new_project.developers_with_callback_ids = [developers[2].id, developers[3].id] @@ -310,7 +312,11 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_build devel = Developer.find(1) - proj = assert_no_queries(ignore_none: false) { devel.projects.build("name" => "Projekt") } + + # Load schema information so we don't query below if running just this test. + Project.define_attribute_methods + + proj = assert_no_queries { devel.projects.build("name" => "Projekt") } assert_not_predicate devel.projects, :loaded? assert_equal devel.projects.last, proj @@ -325,7 +331,11 @@ 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") } + + # Load schema information so we don't query below if running just this test. + Project.define_attribute_methods + + proj = assert_no_queries { devel.projects.new("name" => "Projekt") } assert_not_predicate devel.projects, :loaded? assert_equal devel.projects.last, proj @@ -546,7 +556,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase developer = project.developers.first - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_predicate project.developers, :loaded? assert_includes project.developers, developer end @@ -569,7 +579,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase developer = Developer.create name: "Bryan", salary: 50_000 assert_not_predicate project.developers, :loaded? - assert ! project.developers.include?(developer) + assert_not project.developers.include?(developer) end def test_find_with_merged_options @@ -697,24 +707,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}" @@ -723,10 +722,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 @@ -755,7 +751,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_get_ids_for_loaded_associations developer = developers(:david) developer.projects.reload - assert_queries(0) do + assert_no_queries do developer.project_ids developer.project_ids end @@ -786,6 +782,16 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort end + def test_singular_ids_are_reloaded_after_collection_concat + student = Student.create(name: "Alberto Almagro") + student.lesson_ids + + lesson = Lesson.create(name: "DSI") + student.lessons << lesson + + assert_includes student.lesson_ids, lesson.id + end + def test_scoped_find_on_through_association_doesnt_return_read_only_records tag = Post.find(1).tags.find_by_name("General") @@ -795,7 +801,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 @@ -873,7 +879,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_has_and_belongs_to_many_associations_on_new_records_use_null_relations projects = Developer.new.projects - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal [], projects assert_equal [], projects.where(title: "omg") assert_equal [], projects.pluck(:title) @@ -1001,16 +1007,14 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end def test_has_and_belongs_to_many_while_partial_writes_false - begin - original_partial_writes = ActiveRecord::Base.partial_writes - ActiveRecord::Base.partial_writes = false - developer = Developer.new(name: "Mehmet Emin İNAÇ") - developer.projects << Project.new(name: "Bounty") - - assert developer.save - ensure - ActiveRecord::Base.partial_writes = original_partial_writes - end + original_partial_writes = ActiveRecord::Base.partial_writes + ActiveRecord::Base.partial_writes = false + developer = Developer.new(name: "Mehmet Emin İNAÇ") + developer.projects << Project.new(name: "Bounty") + + assert developer.save + ensure + ActiveRecord::Base.partial_writes = original_partial_writes end def test_has_and_belongs_to_many_with_belongs_to diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index a64432fae7..6a7efe2121 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -114,7 +114,7 @@ end class HasManyAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :categories, :companies, :developers, :projects, :developers_projects, :topics, :authors, :author_addresses, :comments, - :posts, :readers, :taggings, :cars, :jobs, :tags, + :posts, :readers, :taggings, :cars, :tags, :categorizations, :zines, :interests def setup @@ -265,7 +265,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase car = Car.create(name: "honda") car.funky_bulbs.create! assert_equal 1, car.funky_bulbs.count - assert_nothing_raised { car.reload.funky_bulbs.delete_all } + assert_equal 1, car.reload.funky_bulbs.delete_all assert_equal 0, car.funky_bulbs.count, "bulbs should have been deleted using :delete_all strategy" end @@ -295,6 +295,14 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal(expected_sql, loaded_sql) end + def test_delete_all_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + posts.create!(title: "test", body: "body") + posts.delete_all + assert_nil posts.first + end + def test_building_the_associated_object_with_implicit_sti_base_class firm = DependentFirm.new company = firm.companies.build @@ -377,6 +385,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 @@ -438,7 +467,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_finder_method_with_dirty_target company = companies(:first_firm) new_clients = [] - assert_no_queries(ignore_none: false) do + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + assert_no_queries do new_clients << company.clients_of_firm.build(name: "Another Client") new_clients << company.clients_of_firm.build(name: "Another Client II") new_clients << company.clients_of_firm.build(name: "Another Client III") @@ -458,7 +491,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_finder_bang_method_with_dirty_target company = companies(:first_firm) new_clients = [] - assert_no_queries(ignore_none: false) do + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + assert_no_queries do new_clients << company.clients_of_firm.build(name: "Another Client") new_clients << company.clients_of_firm.build(name: "Another Client II") new_clients << company.clients_of_firm.build(name: "Another Client III") @@ -801,6 +838,48 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_not_same original_object, collection.first, "Expected #first after #reload to return a new object" end + def test_reload_with_query_cache + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + # Populate the cache with a query + firm = Firm.first + # Populate the cache with a second query + firm.clients.load + + assert_equal 2, connection.query_cache.size + + # Clear the cache and fetch the clients again, populating the cache with a query + assert_queries(1) { firm.clients.reload } + # This query is cached, so it shouldn't make a real SQL query + assert_queries(0) { firm.clients.load } + + assert_equal 1, connection.query_cache.size + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + + def test_reloading_unloaded_associations_with_query_cache + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + firm = Firm.create!(name: "firm name") + client = firm.clients.create!(name: "client name") + firm.clients.to_a # add request to cache + + connection.uncached do + client.update!(name: "new client name") + end + + firm = Firm.find(firm.id) + + assert_equal [client.name], firm.clients.reload.map(&:name) + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + def test_find_all_with_include_and_conditions assert_nothing_raised do Developer.all.merge!(joins: :audit_logs, where: { "audit_logs.message" => nil, :name => "Smith" }).to_a @@ -917,9 +996,10 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_predicate companies(:first_firm).clients_of_firm, :loaded? - companies(:first_firm).clients_of_firm.concat([Client.new("name" => "Natural Company"), Client.new("name" => "Apple")]) + result = companies(:first_firm).clients_of_firm.concat([Client.new("name" => "Natural Company"), Client.new("name" => "Apple")]) assert_equal 4, companies(:first_firm).clients_of_firm.size assert_equal 4, companies(:first_firm).clients_of_firm.reload.size + assert_equal companies(:first_firm).clients_of_firm, result end def test_transactions_when_adding_to_persisted @@ -935,8 +1015,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_transactions_when_adding_to_new_record - assert_no_queries(ignore_none: false) do - firm = Firm.new + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + firm = Firm.new + assert_no_queries do firm.clients_of_firm.concat(Client.new("name" => "Natural Company")) end end @@ -950,7 +1033,11 @@ 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") } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.new("name" => "Another Client") } assert_not_predicate company.clients_of_firm, :loaded? assert_equal "Another Client", new_client.name @@ -960,7 +1047,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_build company = companies(:first_firm) - new_client = assert_no_queries(ignore_none: false) { company.clients_of_firm.build("name" => "Another Client") } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") } assert_not_predicate company.clients_of_firm, :loaded? assert_equal "Another Client", new_client.name @@ -983,6 +1074,26 @@ class HasManyAssociationsTest < ActiveRecord::TestCase 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 post = posts(:thinking) assert_equal 0, post.readers.size @@ -997,7 +1108,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_build_many company = companies(:first_firm) - new_clients = assert_no_queries(ignore_none: false) { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_clients = assert_no_queries { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) } assert_equal 2, new_clients.size end @@ -1009,10 +1124,12 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_build_without_loading_association first_topic = topics(:first) - Reply.column_names assert_equal 1, first_topic.replies.length + # Load schema information so we don't query below if running just this test. + Reply.define_attribute_methods + assert_no_queries do first_topic.replies.build(title: "Not saved", content: "Superstars") assert_equal 2, first_topic.replies.size @@ -1023,7 +1140,11 @@ 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" } } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build { |client| client.name = "Another Client" } } assert_not_predicate company.clients_of_firm, :loaded? assert_equal "Another Client", new_client.name @@ -1033,7 +1154,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_build_many_via_block company = companies(:first_firm) - new_clients = assert_no_queries(ignore_none: false) do + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_clients = assert_no_queries do company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) do |client| client.name = "changed" end @@ -1046,8 +1171,6 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_create_without_loading_association first_firm = companies(:first_firm) - Firm.column_names - Client.column_names assert_equal 2, first_firm.clients_of_firm.size first_firm.clients_of_firm.reset @@ -1102,7 +1225,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_has_many_without_counter_cache_option # Ship has a conventionally named `treasures_count` column, but the counter_cache # option is not given on the association. - ship = Ship.create(name: "Countless", treasures_count: 10) + ship = Ship.create!(name: "Countless", treasures_count: 10) assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter? @@ -1157,6 +1280,38 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal 2, topic.reload.replies.size end + def test_counter_cache_updates_in_memory_after_update_with_inverse_of_disabled + topic = Topic.create!(title: "Zoom-zoom-zoom") + + assert_equal 0, topic.replies_count + + reply1 = Reply.create!(title: "re: zoom", content: "speedy quick!") + reply2 = Reply.create!(title: "re: zoom 2", content: "OMG lol!") + + assert_queries(4) do + topic.replies << [reply1, reply2] + end + + assert_equal 2, topic.replies_count + assert_equal 2, topic.reload.replies_count + end + + def test_counter_cache_updates_in_memory_after_update_with_inverse_of_enabled + category = Category.create!(name: "Counter Cache") + + assert_nil category.categorizations_count + + categorization1 = Categorization.create! + categorization2 = Categorization.create! + + assert_queries(4) do + category.categorizations << [categorization1, categorization2] + end + + assert_equal 2, category.categorizations_count + assert_equal 2, category.reload.categorizations_count + end + def test_pushing_association_updates_counter_cache topic = Topic.order("id ASC").first reply = Reply.create! @@ -1194,7 +1349,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_calling_empty_with_counter_cache post = posts(:welcome) - assert_queries(0) do + assert_no_queries do assert_not_empty post.comments end end @@ -1260,7 +1415,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal 3, clients.count assert_difference "Client.count", -(clients.count) do - companies(:first_firm).dependent_clients_of_firm.delete_all + assert_equal clients.count, companies(:first_firm).dependent_clients_of_firm.delete_all end end @@ -1292,8 +1447,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_transaction_when_deleting_new_record - assert_no_queries(ignore_none: false) do - firm = Firm.new + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + firm = Firm.new + assert_no_queries do client = Client.new("name" => "New Client") firm.clients_of_firm << client firm.clients_of_firm.destroy(client) @@ -1354,10 +1512,20 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_delete_all_with_option_delete_all firm = companies(:first_firm) client_id = firm.dependent_clients_of_firm.first.id - firm.dependent_clients_of_firm.delete_all(:delete_all) + count = firm.dependent_clients_of_firm.count + assert_equal count, firm.dependent_clients_of_firm.delete_all(:delete_all) assert_nil Client.find_by_id(client_id) end + def test_delete_all_with_option_nullify + firm = companies(:first_firm) + client_id = firm.dependent_clients_of_firm.first.id + count = firm.dependent_clients_of_firm.count + assert_equal firm, Client.find(client_id).firm + assert_equal count, firm.dependent_clients_of_firm.delete_all(:nullify) + assert_nil Client.find(client_id).firm + end + def test_delete_all_accepts_limited_parameters firm = companies(:first_firm) assert_raise(ArgumentError) do @@ -1554,7 +1722,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" @@ -1562,6 +1730,38 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert companies(:first_firm).clients_of_firm.reload.empty?, "37signals has no clients after destroy all and refresh" end + def test_destroy_all_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + posts.create!(title: "test", body: "body") + posts.destroy_all + assert_nil posts.first + end + + def test_destroy_all_on_desynced_counter_cache_association + category = categories(:general) + assert_operator category.categorizations.count, :>, 0 + + category.categorizations.destroy_all + assert_equal 0, category.categorizations.count + end + + def test_destroy_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + post = posts.create!(title: "test", body: "body") + posts.destroy(post) + assert_nil posts.first + end + + def test_delete_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + post = posts.create!(title: "test", body: "body") + posts.delete(post) + assert_nil posts.first + end + def test_dependence firm = companies(:first_firm) assert_equal 3, firm.clients.size @@ -1625,6 +1825,22 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal num_accounts, Account.count end + def test_depends_and_nullify_on_polymorphic_assoc + author = PersonWithPolymorphicDependentNullifyComments.create!(first_name: "Laertis") + comment = posts(:welcome).comments.first + comment.author = author + comment.save! + + assert_equal comment.author_id, author.id + assert_equal comment.author_type, author.class.name + + author.destroy + comment.reload + + assert_nil comment.author_id + assert_nil comment.author_type + end + def test_restrict_with_exception firm = RestrictedWithExceptionFirm.create!(name: "restrict") firm.companies.create(name: "child") @@ -1728,7 +1944,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm.clients = [] firm.save - assert_queries(0, ignore_none: true) do + assert_no_queries do firm.clients = [] end @@ -1750,8 +1966,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_transactions_when_replacing_on_new_record - assert_no_queries(ignore_none: false) do - firm = Firm.new + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + firm = Firm.new + assert_no_queries do firm.clients_of_firm = [Client.new("name" => "New Client")] end end @@ -1763,7 +1982,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_get_ids_for_loaded_associations company = companies(:first_firm) company.clients.reload - assert_queries(0) do + assert_no_queries do company.client_ids company.client_ids end @@ -1778,7 +1997,22 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_counter_cache_on_unloaded_association car = Car.create(name: "My AppliCar") - assert_equal car.engines.size, 0 + assert_equal 0, car.engines.size + end + + def test_ids_reader_cache_not_used_for_size_when_association_is_dirty + firm = Firm.create!(name: "Startup") + assert_equal 0, firm.client_ids.size + firm.clients.build + assert_equal 1, firm.clients.size + end + + def test_ids_reader_cache_should_be_cleared_when_collection_is_deleted + firm = companies(:first_firm) + assert_equal [2, 3, 11], firm.client_ids + client = firm.clients.first + firm.clients.delete(client) + assert_equal [3, 11], firm.client_ids end def test_get_ids_ignores_include_option @@ -1790,11 +2024,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_get_ids_for_association_on_new_record_does_not_try_to_find_records - Company.columns # Load schema information so we don't query below - Contract.columns # if running just this test. + # Load schema information so we don't query below if running just this test. + companies(:first_client).contract_ids company = Company.new - assert_queries(0) do + assert_no_queries do company.contract_ids end @@ -1839,10 +2073,12 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_associations_order_should_be_priority_over_throughs_order - david = authors(:david) + original = authors(:david) expected = [12, 10, 9, 8, 7, 6, 5, 3, 2, 1] - assert_equal expected, david.comments_desc.map(&:id) - assert_equal expected, Author.includes(:comments_desc).find(david.id).comments_desc.map(&:id) + assert_equal expected, original.comments_desc.map(&:id) + preloaded = Author.includes(:comments_desc).find(original.id) + assert_equal expected, preloaded.comments_desc.map(&:id) + assert_equal original.posts_sorted_by_id.first.comments.map(&:id), preloaded.posts_sorted_by_id.first.comments.map(&:id) end def test_dynamic_find_should_respect_association_order_for_through @@ -1851,8 +2087,8 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_has_many_through_respects_hash_conditions - assert_equal authors(:david).hello_posts, authors(:david).hello_posts_with_hash_conditions - assert_equal authors(:david).hello_post_comments, authors(:david).hello_post_comments_with_hash_conditions + assert_equal authors(:david).hello_posts.sort_by(&:id), authors(:david).hello_posts_with_hash_conditions.sort_by(&:id) + assert_equal authors(:david).hello_post_comments.sort_by(&:id), authors(:david).hello_post_comments_with_hash_conditions.sort_by(&:id) end def test_include_uses_array_include_after_loaded @@ -1900,7 +2136,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase firm.clients.load_target assert_predicate firm.clients, :loaded? - assert_no_queries(ignore_none: false) do + assert_no_queries do firm.clients.first assert_equal 2, firm.clients.first(2).size firm.clients.last @@ -1976,8 +2212,9 @@ class HasManyAssociationsTest < ActiveRecord::TestCase 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_predicate firm.clients, :loaded? end @@ -2009,14 +2246,15 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_calling_none_on_loaded_association_should_not_use_query firm = companies(:first_firm) firm.clients.load # force load - assert_no_queries { assert ! firm.clients.none? } + 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_predicate firm.clients, :loaded? end @@ -2044,14 +2282,15 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_calling_one_on_loaded_association_should_not_use_query firm = companies(:first_firm) firm.clients.load # force load - assert_no_queries { assert ! firm.clients.one? } + 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_predicate firm.clients, :loaded? end @@ -2092,9 +2331,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 @@ -2110,21 +2350,29 @@ class HasManyAssociationsTest < ActiveRecord::TestCase 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 @@ -2270,6 +2518,19 @@ class HasManyAssociationsTest < ActiveRecord::TestCase def test_collection_association_with_private_kernel_method firm = companies(:first_firm) assert_equal [accounts(:signals37)], firm.accounts.open + assert_equal [accounts(:signals37)], firm.accounts.available + end + + def test_association_with_or_doesnt_set_inverse_instance_key + firm = companies(:first_firm) + accounts = firm.accounts.or(Account.where(firm_id: nil)).order(:id) + assert_equal [firm.id, nil], accounts.map(&:firm_id) + end + + def test_association_with_rewhere_doesnt_set_inverse_instance_key + firm = companies(:first_firm) + accounts = firm.accounts.rewhere(firm_id: [firm.id, nil]).order(:id) + assert_equal [firm.id, nil], accounts.map(&:firm_id) end test "first_or_initialize adds the record to the association" do @@ -2301,7 +2562,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase test "has many associations on new records use null relations" do post = Post.new - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal [], post.comments assert_equal [], post.comments.where(body: "omg") assert_equal [], post.comments.pluck(:body) @@ -2563,6 +2824,70 @@ class HasManyAssociationsTest < ActiveRecord::TestCase 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, @@ -2617,6 +2942,22 @@ 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 + + def test_has_many_with_out_of_range_value + reference = Reference.create!(id: 2147483648) # out of range in the integer + assert_equal [], reference.ideal_jobs + 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 3b3d4037b9..c13789f7ec 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -33,6 +33,9 @@ require "models/organization" require "models/user" require "models/family" require "models/family_tree" +require "models/section" +require "models/seminar" +require "models/session" class HasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags, @@ -46,11 +49,24 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase Reader.create person_id: 0, post_id: 0 end + def test_has_many_through_create_record + assert books(:awdr).subscribers.create!(nick: "bob") + end + def test_marshal_dump preloaded = Post.includes(:first_blue_tags).first assert_equal preloaded, Marshal.load(Marshal.dump(preloaded)) end + def test_preload_with_nested_association + posts = Post.preload(:author, :author_favorites_with_scope).to_a + + assert_no_queries do + posts.each(&:author) + posts.each(&:author_favorites_with_scope) + end + end + def test_preload_sti_rhs_class developers = Developer.includes(:firms).all.to_a assert_no_queries do @@ -71,6 +87,15 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase club1.members.sort_by(&:id) end + def test_preload_multiple_instances_of_the_same_record + club = Club.create!(name: "Aaron cool banana club") + Membership.create! club: club, member: Member.create!(name: "Aaron") + Membership.create! club: club, member: Member.create!(name: "Bob") + + preloaded_clubs = Club.joins(:memberships).preload(:membership).to_a + assert_no_queries { preloaded_clubs.each(&:membership) } + end + def test_ordered_has_many_through person_prime = Class.new(ActiveRecord::Base) do def self.name; "Person"; end @@ -191,7 +216,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_no_difference "Job.count" do assert_difference "Reference.count", -1 do - person.reload.jobs_with_dependent_destroy.delete_all + assert_equal 1, person.reload.jobs_with_dependent_destroy.delete_all end end end @@ -202,7 +227,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_no_difference "Job.count" do assert_no_difference "Reference.count" do - person.reload.jobs_with_dependent_nullify.delete_all + assert_equal 1, person.reload.jobs_with_dependent_nullify.delete_all end end end @@ -213,17 +238,26 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_no_difference "Job.count" do assert_difference "Reference.count", -1 do - person.reload.jobs_with_dependent_delete_all.delete_all + assert_equal 1, person.reload.jobs_with_dependent_delete_all.delete_all end end end + def test_delete_all_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + people.create!(first_name: "Jeb") + people.delete_all + assert_nil people.first + end + def test_concat person = people(:david) post = posts(:thinking) - post.people.concat [person] + result = post.people.concat [person] assert_equal 1, post.people.size assert_equal 1, post.people.reload.size + assert_equal post.people, result end def test_associate_existing_record_twice_should_add_to_target_twice @@ -265,7 +299,10 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_queries(1) { posts(:thinking) } new_person = nil # so block binding catches it - assert_queries(0) do + # Load schema information so we don't query below if running just this test. + Person.define_attribute_methods + + assert_no_queries do new_person = Person.new first_name: "bob" end @@ -285,7 +322,10 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase def test_associate_new_by_building assert_queries(1) { posts(:thinking) } - assert_queries(0) do + # Load schema information so we don't query below if running just this test. + Person.define_attribute_methods + + assert_no_queries do posts(:thinking).people.build(first_name: "Bob") posts(:thinking).people.new(first_name: "Ted") end @@ -351,7 +391,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_delete_association - assert_queries(2) { posts(:welcome);people(:michael); } + assert_queries(2) { posts(:welcome); people(:michael); } assert_queries(1) do posts(:welcome).people.delete(people(:michael)) @@ -386,6 +426,30 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_empty posts(:welcome).people.reload end + def test_destroy_all_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + people.create!(first_name: "Jeb") + people.destroy_all + assert_nil people.first + end + + def test_destroy_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + person = people.create!(first_name: "Jeb") + people.destroy(person) + assert_nil people.first + end + + def test_delete_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + person = people.create!(first_name: "Jeb") + people.delete(person) + assert_nil people.first + end + def test_should_raise_exception_for_destroying_mismatching_records assert_no_difference ["Person.count", "Reader.count"] do assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:welcome).people.destroy(posts(:thinking)) } @@ -554,7 +618,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_replace_association - assert_queries(4) { posts(:welcome);people(:david);people(:michael); posts(:welcome).people.reload } + assert_queries(4) { posts(:welcome); people(:david); people(:michael); posts(:welcome).people.reload } # 1 query to delete the existing reader (michael) # 1 query to associate the new reader (david) @@ -562,15 +626,25 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase posts(:welcome).people = [people(:david)] end - assert_queries(0) { + assert_no_queries do assert_includes posts(:welcome).people, people(:david) assert_not_includes posts(:welcome).people, people(:michael) - } + end assert_includes posts(:welcome).reload.people.reload, people(:david) assert_not_includes posts(:welcome).reload.people.reload, people(:michael) end + def test_replace_association_with_duplicates + post = posts(:thinking) + person = people(:david) + + assert_difference "post.people.count", 2 do + post.people = [person] + post.people = [person, person] + end + end + def test_replace_order_is_preserved posts(:welcome).people.clear posts(:welcome).people = [people(:david), people(:michael)] @@ -683,13 +757,13 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_clear_associations - assert_queries(2) { posts(:welcome);posts(:welcome).people.reload } + assert_queries(2) { posts(:welcome); posts(:welcome).people.reload } assert_queries(1) do posts(:welcome).people.clear end - assert_queries(0) do + assert_no_queries do assert_empty posts(:welcome).people end @@ -737,6 +811,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 @@ -767,7 +853,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase def test_get_ids_for_loaded_associations person = people(:michael) person.posts.reload - assert_queries(0) do + assert_no_queries do person.post_ids person.post_ids end @@ -866,7 +952,7 @@ 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_not Categorization.exists?(author_id: author.id, named_category_name: category.name) assert_empty author.named_categories.reload end @@ -1177,7 +1263,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_associations_on_new_records_use_null_relations person = Person.new - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal [], person.posts assert_equal [], person.posts.where(body: "omg") assert_equal [], person.posts.pluck(:body) @@ -1277,6 +1363,10 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase 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 @@ -1387,6 +1477,37 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [subscription2], post.subscriptions.to_a end + def test_child_is_visible_to_join_model_in_add_association_callbacks + [:before_add, :after_add].each do |callback_name| + sentient_treasure = Class.new(Treasure) do + def self.name; "SentientTreasure"; end + + has_many :pet_treasures, foreign_key: :treasure_id, callback_name => :check_pet! + has_many :pets, through: :pet_treasures + + def check_pet!(added) + raise "No pet!" if added.pet.nil? + end + end + + treasure = sentient_treasure.new + assert_nothing_raised { treasure.pets << pets(:mochi) } + end + end + + def test_circular_autosave_association_correctly_saves_multiple_records + cs180 = Seminar.new(name: "CS180") + fall = Session.new(name: "Fall") + sections = [ + cs180.sections.build(short_name: "A"), + cs180.sections.build(short_name: "B"), + ] + fall.sections << sections + fall.save! + fall.reload + assert_equal sections, fall.sections.sort_by(&:id) + end + private def make_model(name) Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb index b90edf9b13..7bb629466d 100644 --- a/activerecord/test/cases/associations/has_one_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -12,6 +12,9 @@ require "models/bulb" require "models/author" require "models/image" require "models/post" +require "models/drink_designer" +require "models/chef" +require "models/department" class HasOneAssociationsTest < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? @@ -22,25 +25,29 @@ class HasOneAssociationsTest < ActiveRecord::TestCase end def test_has_one - assert_equal companies(:first_firm).account, Account.find(1) - assert_equal Account.find(1).credit_limit, companies(:first_firm).account.credit_limit + firm = companies(:first_firm) + first_account = Account.find(1) + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + assert_equal first_account, firm.account + assert_equal first_account.credit_limit, firm.account.credit_limit + end end def test_has_one_does_not_use_order_by ActiveRecord::SQLCounter.clear_log companies(:first_firm).account ensure - log_all = ActiveRecord::SQLCounter.log_all - assert log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query: #{log_all}" + sql_log = ActiveRecord::SQLCounter.log + assert sql_log.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query: #{sql_log}" end def test_has_one_cache_nils firm = companies(:another_firm) assert_queries(1) { assert_nil firm.account } - assert_queries(0) { assert_nil firm.account } + assert_no_queries { assert_nil firm.account } - firms = Firm.all.merge!(includes: :account).to_a - assert_queries(0) { firms.each(&:account) } + firms = Firm.includes(:account).to_a + assert_no_queries { firms.each(&:account) } end def test_with_select @@ -110,6 +117,21 @@ class HasOneAssociationsTest < ActiveRecord::TestCase assert_nil Account.find(old_account_id).firm_id end + def test_nullify_on_polymorphic_association + department = Department.create! + designer = DrinkDesignerWithPolymorphicDependentNullifyChef.create! + chef = department.chefs.create!(employable: designer) + + assert_equal chef.employable_id, designer.id + assert_equal chef.employable_type, designer.class.name + + designer.destroy! + chef.reload + + assert_nil chef.employable_id + assert_nil chef.employable_type + end + def test_nullification_on_destroyed_association developer = Developer.create!(name: "Someone") ship = Ship.create!(name: "Planet Caravan", developer: developer) @@ -231,9 +253,13 @@ class HasOneAssociationsTest < ActiveRecord::TestCase end def test_build_association_dont_create_transaction - assert_no_queries(ignore_none: false) { - Firm.new.build_account - } + # Load schema information so we don't query below if running just this test. + Account.define_attribute_methods + + firm = Firm.new + assert_no_queries do + firm.build_account + end end def test_building_the_associated_object_with_implicit_sti_base_class @@ -329,6 +355,29 @@ class HasOneAssociationsTest < ActiveRecord::TestCase assert_equal 80, odegy.reload_account.credit_limit end + def test_reload_association_with_query_cache + odegy_id = companies(:odegy).id + + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + # Populate the cache with a query + odegy = Company.find(odegy_id) + # Populate the cache with a second query + odegy.account + + assert_equal 2, connection.query_cache.size + + # Clear the cache and fetch the account again, populating the cache with a query + assert_queries(1) { odegy.reload_account } + + # This query is not cached anymore, so it should make a real SQL query + assert_queries(1) { Company.find(odegy_id) } + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + def test_build firm = Firm.new("name" => "GlobalMegaCorp") firm.save @@ -452,7 +501,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase assert_equal new_ship, pirate.ship 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 @@ -661,6 +710,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 @@ -678,6 +729,7 @@ class HasOneAssociationsTest < ActiveRecord::TestCase book = SpecialBook.create!(status: "published") author.book = book + assert_equal "published", book.status assert_not_equal 0, SpecialAuthor.joins(:book).where(books: { status: "published" }).count 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 9964f084ac..69b4872519 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -35,6 +35,13 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase assert_equal clubs(:boring_club), @member.club end + def test_has_one_through_executes_limited_query + boring_club = clubs(:boring_club) + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + assert_equal boring_club, @member.general_club + end + end + def test_creating_association_creates_through_record new_member = Member.create(name: "Chris") new_member.club = Club.create(name: "LRUG") @@ -64,6 +71,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") diff --git a/activerecord/test/cases/associations/inner_join_association_test.rb b/activerecord/test/cases/associations/inner_join_association_test.rb index ca0620dc3b..e0dac01f4a 100644 --- a/activerecord/test/cases/associations/inner_join_association_test.rb +++ b/activerecord/test/cases/associations/inner_join_association_test.rb @@ -29,7 +29,10 @@ class InnerJoinAssociationTest < ActiveRecord::TestCase def test_construct_finder_sql_does_not_table_name_collide_on_duplicate_associations_with_left_outer_joins sql = Person.joins(agents: :agents).left_outer_joins(agents: :agents).to_sql - assert_match(/agents_people_4/i, sql) + assert_match(/agents_people_2/i, sql) + assert_match(/INNER JOIN/i, sql) + assert_no_match(/agents_people_4/i, sql) + assert_no_match(/LEFT OUTER JOIN/i, sql) end def test_construct_finder_sql_does_not_table_name_collide_with_string_joins @@ -79,19 +82,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 diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb index 896bf574f4..da3a42e2b5 100644 --- a/activerecord/test/cases/associations/inverse_associations_test.rb +++ b/activerecord/test/cases/associations/inverse_associations_test.rb @@ -119,11 +119,11 @@ class AutomaticInverseFindingTests < ActiveRecord::TestCase def test_polymorphic_and_has_many_through_relationships_should_not_have_inverses sponsor_reflection = Sponsor.reflect_on_association(:sponsorable) - 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 !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_has_one_should_find_inverse_automatically diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index 57ebc1e3e0..9d1c73c33b 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -732,7 +732,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase category = Category.create!(name: "Not Associated") assert_not_predicate david.categories, :loaded? - assert ! david.categories.include?(category) + 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 0e54e8c1b0..0a8863c35d 100644 --- a/activerecord/test/cases/associations/left_outer_join_association_test.rb +++ b/activerecord/test/cases/associations/left_outer_join_association_test.rb @@ -46,6 +46,12 @@ class LeftOuterJoinAssociationTest < ActiveRecord::TestCase assert queries.any? { |sql| /LEFT OUTER JOIN/i.match?(sql) } end + def test_left_outer_joins_is_deduped_when_same_association_is_joined + queries = capture_sql { Author.joins(:posts).left_outer_joins(:posts).to_a } + assert queries.any? { |sql| /INNER JOIN/i.match?(sql) } + assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) } + end + def test_construct_finder_sql_ignores_empty_left_outer_joins_hash queries = capture_sql { Author.left_outer_joins({}).to_a } assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) } @@ -60,6 +66,10 @@ class LeftOuterJoinAssociationTest < ActiveRecord::TestCase assert_raise(ArgumentError) { Author.left_outer_joins('LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"').to_a } end + def test_left_outer_joins_with_string_join + assert_equal 16, Author.left_outer_joins(:posts).joins("LEFT OUTER JOIN comments ON comments.post_id = posts.id").count + end + def test_join_conditions_added_to_join_clause queries = capture_sql { Author.left_outer_joins(:essays).to_a } assert queries.any? { |sql| /writer_type.*?=.*?(Author|\?|\$1|\:a1)/i.match?(sql) } diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index 03ed1c1d47..35da74102d 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -137,7 +137,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_one_through_with_has_one_source_reflection_preload members = assert_queries(4) { Member.includes(:nested_sponsors).to_a } mustache = sponsors(:moustache_club_sponsor_for_groucho) - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal [mustache], members.first.nested_sponsors end end @@ -196,7 +196,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase # postgresql test if randomly executed then executes "SHOW max_identifier_length". Hence # the need to ignore certain predefined sqls that deal with system calls. - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id) end end @@ -548,6 +548,15 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase end end + def test_through_association_preload_doesnt_reset_source_association_if_already_preloaded + blue = tags(:blue) + authors = Author.preload(posts: :first_blue_tags_2, misc_post_first_blue_tags_2: {}).to_a.sort_by(&:id) + + assert_no_queries do + assert_equal [blue], authors[2].posts.first.first_blue_tags_2 + end + end + def test_nested_has_many_through_with_conditions_on_source_associations_preload_via_joins # Pointless condition to force single-query loading assert_includes_and_joins_equal( @@ -610,6 +619,12 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase assert_equal hotel, Hotel.joins(:cake_designers, :drink_designers).take end + def test_has_many_through_reset_source_reflection_after_loading_is_complete + preloaded = Category.preload(:ordered_post_comments).find(1, 2).last + original = Category.find(2) + assert_equal original.ordered_post_comments.ids, preloaded.ordered_post_comments.ids + end + private def assert_includes_and_joins_equal(query, expected, association) diff --git a/activerecord/test/cases/associations/required_test.rb b/activerecord/test/cases/associations/required_test.rb index 65a3bb5efe..c7a78e6bc4 100644 --- a/activerecord/test/cases/associations/required_test.rb +++ b/activerecord/test/cases/associations/required_test.rb @@ -25,20 +25,18 @@ class RequiredAssociationsTest < ActiveRecord::TestCase 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 + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = false - 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 + 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 test "required belongs_to associations have presence validated" do @@ -56,24 +54,22 @@ class RequiredAssociationsTest < ActiveRecord::TestCase 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 + 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 + 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 = 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 + record.parent = Parent.new + assert record.save + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value end test "has_one associations are not required by default" do diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index fce2a7eed1..84130ec208 100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -21,6 +21,11 @@ require "models/molecule" require "models/electron" require "models/man" require "models/interest" +require "models/pirate" +require "models/parrot" +require "models/bird" +require "models/treasure" +require "models/price_estimate" class AssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :developers_projects, @@ -80,7 +85,7 @@ class AssociationsTest < ActiveRecord::TestCase def test_force_reload firm = Firm.new("name" => "A New Firm, Inc") firm.save - firm.clients.each {} # forcing to load all clients + firm.clients.each { } # forcing to load all clients assert firm.clients.empty?, "New firm shouldn't have client objects" assert_equal 0, firm.clients.size, "New firm should have 0 clients" @@ -92,7 +97,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 @@ -102,8 +107,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 @@ -368,3 +373,97 @@ class GeneratedMethodsTest < ActiveRecord::TestCase assert_equal :none, MyArticle.new.comments end end + +class WithAnnotationsTest < ActiveRecord::TestCase + fixtures :pirates, :parrots + + def test_belongs_to_with_annotation_includes_a_query_comment + pirate = SpacePirate.where.not(parrot_id: nil).first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.parrot + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that tells jokes \*/}) do + pirate.parrot_with_annotation + end + end + + def test_has_and_belongs_to_many_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.parrots.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that are very colorful \*/}) do + pirate.parrots_with_annotation.first + end + end + + def test_has_one_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.ship + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that is a rocket \*/}) do + pirate.ship_with_annotation + end + end + + def test_has_many_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.birds.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that are also parrots \*/}) do + pirate.birds_with_annotation.first + end + end + + def test_has_many_through_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.treasure_estimates.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* yarrr \*/}) do + pirate.treasure_estimates_with_annotation.first + end + end + + def test_has_many_through_with_annotation_includes_a_query_comment_when_eager_loading + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.treasure_estimates.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* yarrr \*/}) do + SpacePirate.includes(:treasure_estimates_with_annotation, :treasures).first + end + end +end diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index dc6638d45d..9fd62dcf72 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -56,6 +56,13 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]", t.attribute_for_inspect(:content) end + test "attribute_for_inspect with a non-primary key id attribute" do + t = topics(:first).becomes(TitlePrimaryKeyTopic) + t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters" + + assert_equal "1", t.attribute_for_inspect(:id) + end + test "attribute_present" do t = Topic.new t.title = "hello there!" @@ -63,8 +70,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 @@ -77,7 +84,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 @@ -163,19 +170,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_not_respond_to topic, "nothingness" - assert_not_respond_to topic, :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 @@ -323,6 +317,18 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal "New topic", topic.title end + test "write_attribute raises ActiveModel::MissingAttributeError when the attribute does not exist" do + topic = Topic.first + assert_raises(ActiveModel::MissingAttributeError) { topic.update_columns(no_column_exists: "Hello!") } + assert_raises(ActiveModel::UnknownAttributeError) { topic.update(no_column_exists: "Hello!") } + end + + test "write_attribute allows writing to aliased attributes" do + topic = Topic.first + assert_nothing_raised { topic.update_columns(heading: "Hello!") } + assert_nothing_raised { topic.update(heading: "Hello!") } + end + test "read_attribute" do topic = Topic.new topic.title = "Don't change the topic" @@ -354,9 +360,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 @@ -370,10 +376,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" @@ -427,6 +433,10 @@ class AttributeMethodsTest < ActiveRecord::TestCase end assert_equal true, Topic.new(author_name: "Name").author_name? + + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + assert_predicate Topic.new(author_name: value), :author_name? + end end test "number attribute predicate" do @@ -448,8 +458,71 @@ class AttributeMethodsTest < ActiveRecord::TestCase end end + test "user-defined text attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_text, :text + end + + topic = klass.new(user_defined_text: "text") + assert_predicate topic, :user_defined_text? + + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + topic = klass.new(user_defined_text: value) + assert_predicate topic, :user_defined_text? + end + end + + test "user-defined date attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_date, :date + end + + topic = klass.new(user_defined_date: Date.current) + assert_predicate topic, :user_defined_date? + end + + test "user-defined datetime attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_datetime, :datetime + end + + topic = klass.new(user_defined_datetime: Time.current) + assert_predicate topic, :user_defined_datetime? + end + + test "user-defined time attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_time, :time + end + + topic = klass.new(user_defined_time: Time.current) + assert_predicate topic, :user_defined_time? + end + + test "user-defined json attribute predicate" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = Topic.table_name + + attribute :user_defined_json, :json + end + + topic = klass.new(user_defined_json: { key: "value" }) + assert_predicate topic, :user_defined_json? + + topic = klass.new(user_defined_json: {}) + assert_not_predicate topic, :user_defined_json? + end + test "custom field attribute predicate" do - object = Company.find_by_sql(<<-SQL).first + object = Company.find_by_sql(<<~SQL).first SELECT c1.*, c2.type as string_value, c2.rating as int_value FROM companies c1, companies c2 WHERE c1.firm_id = c2.id @@ -705,6 +778,10 @@ class AttributeMethodsTest < ActiveRecord::TestCase record.written_on = "Jan 01 00:00:00 2014" assert_equal record, YAML.load(YAML.dump(record)) end + ensure + # NOTE: Reset column info because global topics + # don't have tz-aware attributes by default. + Topic.reset_column_information end test "setting a time zone-aware time in the current time zone" do @@ -736,6 +813,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 @@ -827,7 +914,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 @@ -841,8 +928,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 @@ -996,7 +1083,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase test "generated attribute methods ancestors have correct class" do mod = Topic.send(:generated_attribute_methods) - assert_match %r(GeneratedAttributeMethods), mod.inspect + assert_match %r(Topic::GeneratedAttributeMethods), mod.inspect end private diff --git a/activerecord/test/cases/attributes_test.rb b/activerecord/test/cases/attributes_test.rb index 3bc56694be..a6fb9f0af7 100644 --- a/activerecord/test/cases/attributes_test.rb +++ b/activerecord/test/cases/attributes_test.rb @@ -56,6 +56,17 @@ module ActiveRecord assert_equal 255, UnoverloadedType.type_for_attribute("overloaded_string_with_limit").limit end + test "extra options are forwarded to the type caster constructor" do + klass = Class.new(OverloadedType) do + attribute :starts_at, :datetime, precision: 3, limit: 2, scale: 1 + end + + starts_at_type = klass.type_for_attribute(:starts_at) + assert_equal 3, starts_at_type.precision + assert_equal 2, starts_at_type.limit + assert_equal 1, starts_at_type.scale + end + test "nonexistent attribute" do data = OverloadedType.new(non_existent_decimal: 1) @@ -148,6 +159,20 @@ module ActiveRecord assert_equal 2, klass.new.counter end + test "procs for default values are evaluated even after column_defaults is called" do + klass = Class.new(OverloadedType) do + @@counter = 0 + attribute :counter, :integer, default: -> { @@counter += 1 } + end + + assert_equal 1, klass.new.counter + + # column_defaults will increment the counter since the proc is called + klass.column_defaults + + assert_equal 3, klass.new.counter + end + test "procs are memoized before type casting" do klass = Class.new(OverloadedType) do @@counter = 0 diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index b8243d148a..1a0732c14b 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "models/author" require "models/bird" require "models/post" require "models/comment" @@ -14,6 +15,7 @@ require "models/line_item" require "models/order" require "models/parrot" require "models/pirate" +require "models/project" require "models/ship" require "models/ship_part" require "models/tag" @@ -27,6 +29,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 @@ -116,7 +119,7 @@ class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCas assert_not_predicate firm.account, :valid? assert_not_predicate firm, :valid? - assert !firm.save + assert_not firm.save assert_equal ["is invalid"], firm.errors["account"] end @@ -237,7 +240,7 @@ class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::Test log.developer = Developer.new assert_not_predicate log.developer, :valid? assert_not_predicate log, :valid? - assert !log.save + assert_not log.save assert_equal ["is invalid"], log.errors["developer"] end @@ -499,10 +502,10 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa def test_invalid_adding firm = Firm.find(1) - assert !(firm.clients_of_firm << c = Client.new) + assert_not (firm.clients_of_firm << c = Client.new) assert_not_predicate c, :persisted? assert_not_predicate firm, :valid? - assert !firm.save + assert_not firm.save assert_not_predicate c, :persisted? end @@ -512,11 +515,23 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa assert_not_predicate c, :persisted? assert_not_predicate c, :valid? assert_not_predicate new_firm, :valid? - assert !new_firm.save + 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 firm = Firm.first client = Client.new @@ -545,12 +560,40 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa 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_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_not companies(:first_firm).save assert_not_predicate new_client, :persisted? assert_equal 2, companies(:first_firm).clients_of_firm.reload.size end @@ -600,7 +643,11 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa 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") } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") } assert_not_predicate company.clients_of_firm, :loaded? company.name += "-changed" @@ -611,7 +658,11 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa def test_build_many_before_save company = companies(:first_firm) - assert_no_queries(ignore_none: false) { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + assert_no_queries { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) } company.name += "-changed" assert_queries(3) { assert company.save } @@ -620,7 +671,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" } } + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build { |client| client.name = "Another Client" } } assert_not_predicate company.clients_of_firm, :loaded? company.name += "-changed" @@ -631,7 +686,11 @@ class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCa def test_build_many_via_block_before_save company = companies(:first_firm) - assert_no_queries(ignore_none: false) do + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + assert_no_queries do company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) do |client| client.name = "changed" end @@ -769,8 +828,9 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase 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 @@ -795,7 +855,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 @@ -806,8 +866,9 @@ 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 @@ -830,8 +891,9 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase 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 @@ -855,7 +917,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 @@ -871,7 +933,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 @@ -898,11 +960,13 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @pirate.birds.each { |bird| bird.name = "" } 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 @@ -921,8 +985,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 @@ -937,7 +1004,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 @@ -1003,7 +1070,7 @@ 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 @@ -1022,12 +1089,14 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase @pirate.parrots.each { |parrot| parrot.name = "" } 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_empty @pirate.reload.parrots end @@ -1048,7 +1117,7 @@ class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase assert @pirate.save Pirate.transaction do - assert_queries(0) do + assert_no_queries do assert @pirate.save end end @@ -1065,7 +1134,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 @@ -1129,12 +1198,12 @@ class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase def test_changed_for_autosave_should_handle_cycles @ship.pirate = @pirate - assert_queries(0) { @ship.save! } + assert_no_queries { @ship.save! } @parrot = @pirate.parrots.create(name: "some_name") @parrot.name = "changed_name" assert_queries(1) { @ship.save! } - assert_queries(0) { @ship.save! } + assert_no_queries { @ship.save! } end def test_should_automatically_save_bang_the_associated_model @@ -1213,7 +1282,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 @@ -1232,7 +1301,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 @@ -1251,21 +1320,45 @@ end class TestAutosaveAssociationOnAHasOneThroughAssociation < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? - def setup - super + def create_member_with_organization organization = Organization.create - @member = Member.create - MemberDetail.create(organization: organization, member: @member) + member = Member.create + MemberDetail.create(organization: organization, member: member) + + member end def test_should_not_has_one_through_model - class << @member.organization + member = create_member_with_organization + + class << member.organization def save(*args) super raise "Oh noes!" end end - assert_nothing_raised { @member.save } + assert_nothing_raised { member.save } + end + + def create_author_with_post_with_comment + Author.create! name: "David" # make comment_id not match author_id + author = Author.create! name: "Sergiy" + post = Post.create! author: author, title: "foo", body: "bar" + Comment.create! post: post, body: "cool comment" + + author + end + + def test_should_not_reversed_has_one_through_model + author = create_author_with_post_with_comment + + class << author.comment_on_first_post + def save(*args) + super + raise "Oh noes!" + end + end + assert_nothing_raised { author.save } end end @@ -1337,7 +1430,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 @@ -1356,7 +1449,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 @@ -1480,7 +1573,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 @@ -1490,7 +1583,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 @@ -1510,7 +1603,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 @@ -1736,3 +1829,21 @@ class TestAutosaveAssociationOnAHasManyAssociationWithInverse < ActiveRecord::Te assert_equal 1, comment.post_comments_count end end + +class TestAutosaveAssociationOnAHasManyAssociationDefinedInSubclassWithAcceptsNestedAttributes < ActiveRecord::TestCase + def test_should_update_children_when_association_redefined_in_subclass + agency = Agency.create!(name: "Agency") + valid_project = Project.create!(firm: agency, name: "Initial") + agency.update!( + "projects_attributes" => { + "0" => { + "name" => "Updated", + "id" => valid_project.id + } + } + ) + valid_project.reload + + assert_equal "Updated", valid_project.name + end +end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index d5e1c2feb7..99f47cfe37 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -14,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" @@ -283,11 +282,13 @@ class BasicsTest < ActiveRecord::TestCase end def test_initialize_with_invalid_attribute - Topic.new("title" => "test", - "last_read(1i)" => "2005", "last_read(2i)" => "2", "last_read(3i)" => "31") - rescue ActiveRecord::MultiparameterAssignmentErrors => ex + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + Topic.new("title" => "test", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00") + end + assert_equal(1, ex.errors.size) - assert_equal("last_read", ex.errors[0].attribute) + assert_equal("written_on", ex.errors[0].attribute) end def test_create_after_initialize_without_block @@ -307,7 +308,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 @@ -437,12 +438,6 @@ class BasicsTest < ActiveRecord::TestCase Post.reset_table_name end - if current_adapter?(:Mysql2Adapter) - def test_update_all_with_order_and_limit - assert_equal 1, Topic.limit(1).order("id DESC").update_all(content: "bulk updated!") - end - end - def test_null_fields assert_nil Topic.find(1).parent_id assert_nil Topic.create("title" => "Hey you").parent_id @@ -690,6 +685,9 @@ class BasicsTest < ActiveRecord::TestCase topic = Topic.find(1) topic.attributes = attributes assert_equal Time.local(2000, 1, 1, 5, 42, 0), topic.bonus_time + + topic.save! + assert_equal topic, Topic.find_by(attributes) end end @@ -716,48 +714,6 @@ class BasicsTest < ActiveRecord::TestCase assert_equal expected_attributes, category.attributes 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_not_predicate b_false, :value? - b_true = Boolean.find(true_id) - assert_predicate b_true, :value? - end - - def test_boolean_without_questionmark - b_true = Boolean.create("value" => true) - true_id = b_true.id - - subclass = Class.new(Boolean).find true_id - superclass = Boolean.find 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" => "") - 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_not_predicate b_false, :value? - b_true = Boolean.find(true_id) - assert_predicate b_true, :value? - end - def test_new_record_returns_boolean assert_equal false, Topic.new.persisted? assert_equal true, Topic.find(1).persisted? @@ -856,11 +812,11 @@ class BasicsTest < ActiveRecord::TestCase 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_predicate cloned_developer, :name_changed? - assert !cloned_developer.salary_changed? # ... and cloned instance should behave same + assert_not cloned_developer.salary_changed? # ... and cloned instance should behave same end def test_dup_of_saved_object_marks_attributes_as_dirty @@ -875,12 +831,12 @@ class BasicsTest < ActiveRecord::TestCase 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_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 @@ -896,8 +852,7 @@ class BasicsTest < ActiveRecord::TestCase 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 @@ -909,7 +864,10 @@ 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 @@ -982,7 +940,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" @@ -1077,11 +1035,6 @@ class BasicsTest < ActiveRecord::TestCase end end - def test_find_last - last = Developer.last - assert_equal last, Developer.all.merge!(order: "id desc").first - end - def test_last assert_equal Developer.all.merge!(order: "id desc").first, Developer.last end @@ -1097,23 +1050,23 @@ class BasicsTest < ActiveRecord::TestCase end def test_find_ordered_last - last = Developer.all.merge!(order: "developers.salary ASC").last - assert_equal last, Developer.all.merge!(order: "developers.salary ASC").to_a.last + last = Developer.order("developers.salary ASC").last + assert_equal last, Developer.order("developers.salary": "ASC").to_a.last end def test_find_reverse_ordered_last - last = Developer.all.merge!(order: "developers.salary DESC").last - assert_equal last, Developer.all.merge!(order: "developers.salary DESC").to_a.last + last = Developer.order("developers.salary DESC").last + assert_equal last, Developer.order("developers.salary": "DESC").to_a.last end def test_find_multiple_ordered_last - last = Developer.all.merge!(order: "developers.name, developers.salary DESC").last - assert_equal last, Developer.all.merge!(order: "developers.name, developers.salary DESC").to_a.last + last = Developer.order("developers.name, developers.salary DESC").last + assert_equal last, Developer.order(:"developers.name", "developers.salary": "DESC").to_a.last end def test_find_keeps_multiple_order_values - combined = Developer.all.merge!(order: "developers.name, developers.salary").to_a - assert_equal combined, Developer.all.merge!(order: ["developers.name", "developers.salary"]).to_a + combined = Developer.order("developers.name, developers.salary").to_a + assert_equal combined, Developer.order(:"developers.name", :"developers.salary").to_a end def test_find_keeps_multiple_group_values @@ -1265,14 +1218,15 @@ class BasicsTest < ActiveRecord::TestCase end def test_attribute_names - assert_equal ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description"], - Company.attribute_names + expected = ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description", "metadata"] + assert_equal expected, Company.attribute_names end def test_has_attribute assert Company.has_attribute?("id") assert Company.has_attribute?("type") assert Company.has_attribute?("name") + assert Company.has_attribute?("metadata") assert_not Company.has_attribute?("lastname") assert_not Company.has_attribute?("age") end @@ -1486,6 +1440,14 @@ class BasicsTest < ActiveRecord::TestCase assert_not_respond_to developer, :first_name= end + test "when ignored attribute is loaded, cast type should be preferred over DB type" do + developer = AttributedDeveloper.create + developer.update_column :name, "name" + + loaded_developer = AttributedDeveloper.where(id: developer.id).select("*").first + assert_equal "Developer: name", loaded_developer.name + end + test "ignored columns not included in SELECT" do query = Developer.all.to_sql.downcase @@ -1519,4 +1481,64 @@ class BasicsTest < ActiveRecord::TestCase ensure ActiveRecord::Base.protected_environments = previous_protected_environments end + + test "creating a record raises if preventing writes" do + error = assert_raises ActiveRecord::ReadOnlyError do + ActiveRecord::Base.connection.while_preventing_writes do + Bird.create! name: "Bluejay" + end + end + + assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, error.message + end + + test "updating a record raises if preventing writes" do + bird = Bird.create! name: "Bluejay" + + error = assert_raises ActiveRecord::ReadOnlyError do + ActiveRecord::Base.connection.while_preventing_writes do + bird.update! name: "Robin" + end + end + + assert_match %r/\AWrite query attempted while in readonly mode: UPDATE /, error.message + end + + test "deleting a record raises if preventing writes" do + bird = Bird.create! name: "Bluejay" + + error = assert_raises ActiveRecord::ReadOnlyError do + ActiveRecord::Base.connection.while_preventing_writes do + bird.destroy! + end + end + + assert_match %r/\AWrite query attempted while in readonly mode: DELETE /, error.message + end + + test "selecting a record does not raise if preventing writes" do + bird = Bird.create! name: "Bluejay" + + ActiveRecord::Base.connection.while_preventing_writes do + assert_equal bird, Bird.where(name: "Bluejay").first + end + end + + test "an explain query does not raise if preventing writes" do + Bird.create!(name: "Bluejay") + + ActiveRecord::Base.connection.while_preventing_writes do + assert_queries(2) { Bird.where(name: "Bluejay").explain } + end + end + + test "an empty transaction does not raise if preventing writes" do + ActiveRecord::Base.connection.while_preventing_writes do + assert_queries(2, ignore_none: true) do + Bird.transaction do + ActiveRecord::Base.connection.materialize_transactions + end + end + end + end end diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb index c8163901c6..cf6e280898 100644 --- a/activerecord/test/cases/batches_test.rb +++ b/activerecord/test/cases/batches_test.rb @@ -24,7 +24,7 @@ class EachTest < ActiveRecord::TestCase def test_each_should_not_return_query_chain_and_execute_only_one_query assert_queries(1) do - result = Post.find_each(batch_size: 100000) {} + result = Post.find_each(batch_size: 100000) { } assert_nil result end end @@ -155,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".dup + not_a_post = +"not a post" 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 @@ -183,7 +183,7 @@ class EachTest < ActiveRecord::TestCase def test_find_in_batches_should_error_on_ignore_the_order assert_raise(ArgumentError) do - PostWithDefaultScope.find_in_batches(error_on_ignore: true) {} + PostWithDefaultScope.find_in_batches(error_on_ignore: true) { } end end @@ -192,7 +192,7 @@ class EachTest < ActiveRecord::TestCase prev = ActiveRecord::Base.error_on_ignored_order ActiveRecord::Base.error_on_ignored_order = true assert_nothing_raised do - PostWithDefaultScope.find_in_batches(error_on_ignore: false) {} + PostWithDefaultScope.find_in_batches(error_on_ignore: false) { } end ensure # Set back to default @@ -204,7 +204,7 @@ class EachTest < ActiveRecord::TestCase prev = ActiveRecord::Base.error_on_ignored_order ActiveRecord::Base.error_on_ignored_order = true assert_raise(ArgumentError) do - PostWithDefaultScope.find_in_batches() {} + PostWithDefaultScope.find_in_batches() { } end ensure # Set back to default @@ -213,7 +213,7 @@ class EachTest < ActiveRecord::TestCase def test_find_in_batches_should_not_error_by_default assert_nothing_raised do - PostWithDefaultScope.find_in_batches() {} + PostWithDefaultScope.find_in_batches() { } end end @@ -228,7 +228,7 @@ class EachTest < ActiveRecord::TestCase def test_find_in_batches_should_not_modify_passed_options assert_nothing_raised do - Post.find_in_batches({ batch_size: 42, start: 1 }.freeze) {} + Post.find_in_batches({ batch_size: 42, start: 1 }.freeze) { } end end @@ -420,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".dup + not_a_post = +"not a post" def not_a_post.id raise StandardError.new("not_a_post had #id called on it") end @@ -430,7 +430,7 @@ class EachTest < ActiveRecord::TestCase assert_kind_of ActiveRecord::Relation, relation assert_kind_of Post, relation.first - relation = [not_a_post] * relation.count + [not_a_post] * relation.count end end end @@ -446,7 +446,7 @@ class EachTest < ActiveRecord::TestCase def test_in_batches_should_not_modify_passed_options assert_nothing_raised do - Post.in_batches({ of: 42, start: 1 }.freeze) {} + Post.in_batches({ of: 42, start: 1 }.freeze) { } end end @@ -597,15 +597,15 @@ class EachTest < ActiveRecord::TestCase table: table_alias, predicate_builder: predicate_builder ) - posts.find_each {} + 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 {} + Post.find_each { } + Post.find_each { } end end end @@ -624,8 +624,8 @@ class EachTest < ActiveRecord::TestCase 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 {} + Post.find_in_batches { } + Post.find_in_batches { } end end end @@ -644,8 +644,8 @@ class EachTest < ActiveRecord::TestCase 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 {} + Post.in_batches { } + Post.in_batches { } end end end diff --git a/activerecord/test/cases/binary_test.rb b/activerecord/test/cases/binary_test.rb index d5376ece69..58abdb47f7 100644 --- a/activerecord/test/cases/binary_test.rb +++ b/activerecord/test/cases/binary_test.rb @@ -12,7 +12,7 @@ unless current_adapter?(:DB2Adapter) FIXTURES = %w(flowers.jpg example.log test.txt) def test_mixed_encoding - str = "\x80".dup + str = +"\x80" 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 91cc49385c..85685d1d00 100644 --- a/activerecord/test/cases/bind_parameter_test.rb +++ b/activerecord/test/cases/bind_parameter_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "models/topic" +require "models/reply" require "models/author" require "models/post" @@ -34,6 +35,100 @@ if ActiveRecord::Base.connection.prepared_statements ActiveSupport::Notifications.unsubscribe(@subscription) end + def test_statement_cache + @connection.clear_cache! + + topics = Topic.where(id: 1) + assert_equal [1], topics.map(&:id) + assert_includes statement_cache, to_sql_key(topics.arel) + + @connection.clear_cache! + + assert_not_includes statement_cache, to_sql_key(topics.arel) + end + + def test_statement_cache_with_query_cache + @connection.enable_query_cache! + @connection.clear_cache! + + topics = Topic.where(id: 1) + assert_equal [1], topics.map(&:id) + assert_includes statement_cache, to_sql_key(topics.arel) + ensure + @connection.disable_query_cache! + end + + def test_statement_cache_with_find + @connection.clear_cache! + + assert_equal 1, Topic.find(1).id + assert_raises(RecordNotFound) { SillyReply.find(2) } + + topic_sql = cached_statement(Topic, Topic.primary_key) + assert_includes statement_cache, to_sql_key(topic_sql) + + e = assert_raise { cached_statement(SillyReply, SillyReply.primary_key) } + assert_equal "SillyReply has no cached statement by \"id\"", e.message + + replies = SillyReply.where(id: 2).limit(1) + assert_includes statement_cache, to_sql_key(replies.arel) + end + + def test_statement_cache_with_find_by + @connection.clear_cache! + + assert_equal 1, Topic.find_by!(id: 1).id + assert_raises(RecordNotFound) { SillyReply.find_by!(id: 2) } + + topic_sql = cached_statement(Topic, [:id]) + assert_includes statement_cache, to_sql_key(topic_sql) + + e = assert_raise { cached_statement(SillyReply, [:id]) } + assert_equal "SillyReply has no cached statement by [:id]", e.message + + replies = SillyReply.where(id: 2).limit(1) + assert_includes statement_cache, to_sql_key(replies.arel) + end + + def test_statement_cache_with_in_clause + @connection.clear_cache! + + topics = Topic.where(id: [1, 3]) + assert_equal [1, 3], topics.map(&:id) + assert_not_includes statement_cache, to_sql_key(topics.arel) + end + + def test_statement_cache_with_sql_string_literal + @connection.clear_cache! + + topics = Topic.where("topics.id = ?", 1) + assert_equal [1], topics.map(&:id) + assert_not_includes statement_cache, to_sql_key(topics.arel) + end + + def test_too_many_binds + bind_params_length = @connection.send(:bind_params_length) + + topics = Topic.where(id: (1 .. bind_params_length).to_a << 2**63) + assert_equal Topic.count, topics.count + + topics = Topic.where.not(id: (1 .. bind_params_length).to_a << 2**63) + assert_equal 0, topics.count + end + + def test_too_many_binds_with_query_cache + @connection.enable_query_cache! + + bind_params_length = @connection.send(:bind_params_length) + topics = Topic.where(id: (1 .. bind_params_length + 1).to_a) + assert_equal Topic.count, topics.count + + topics = Topic.where.not(id: (1 .. bind_params_length + 1).to_a) + assert_equal 0, topics.count + ensure + @connection.disable_query_cache! + end + def test_bind_from_join_in_subquery subquery = Author.joins(:thinking_posts).where(name: "David") scope = Author.from(subquery, "authors").where(id: 1) @@ -67,17 +162,29 @@ if ActiveRecord::Base.connection.prepared_statements assert_logs_binds(binds) end - def test_deprecate_supports_statement_cache - assert_deprecated { ActiveRecord::Base.connection.supports_statement_cache? } - end - private + def to_sql_key(arel) + sql = @connection.to_sql(arel) + @connection.respond_to?(:sql_key, true) ? @connection.send(:sql_key, sql) : sql + end + + def cached_statement(klass, key) + cache = klass.send(:cached_find_by_statement, key) do + raise "#{klass} has no cached statement by #{key.inspect}" + end + cache.send(:query_builder).instance_variable_get(:@sql) + end + + def statement_cache + @connection.instance_variable_get(:@statements).send(:cache) + end + def assert_logs_binds(binds) payload = { name: "SQL", sql: "select * from topics where id = ?", binds: binds, - type_casted_binds: @connection.type_casted_binds(binds) + type_casted_binds: @connection.send(:type_casted_binds, binds) } event = ActiveSupport::Notifications::Event.new( diff --git a/activerecord/test/cases/boolean_test.rb b/activerecord/test/cases/boolean_test.rb new file mode 100644 index 0000000000..18824004d2 --- /dev/null +++ b/activerecord/test/cases/boolean_test.rb @@ -0,0 +1,52 @@ +# 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 + + def test_find_by_falsy_boolean_symbol + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + b_false = Boolean.create!(value: value) + + assert_not_predicate b_false, :value? + assert_equal b_false, Boolean.find_by(id: b_false.id, value: value.to_s.to_sym) + end + end +end diff --git a/activerecord/test/cases/cache_key_test.rb b/activerecord/test/cases/cache_key_test.rb index 3a569f226e..c27eb8a65d 100644 --- a/activerecord/test/cases/cache_key_test.rb +++ b/activerecord/test/cases/cache_key_test.rb @@ -44,10 +44,88 @@ module ActiveRecord 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 + assert_equal "active_record/cache_key_test/cache_me_with_versions/#{r1.id}-#{r1.updated_at.utc.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 + assert_equal "active_record/cache_key_test/cache_mes/#{r2.id}-#{r2.updated_at.utc.to_s(:usec)}", r2.cache_key_with_version + end + + test "cache_version is the same when it comes from the DB or from the user" do + skip("Mysql2 and PostgreSQL don't return a string value for updated_at") if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_not_called(record_from_db, :updated_at) do + record_from_db.cache_version + end + + assert_equal record.cache_version, record_from_db.cache_version + end + + test "cache_version does not truncate zeros when timestamp ends in zeros" do + skip("Mysql2 and PostgreSQL don't return a string value for updated_at") if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + + travel_to Time.now.beginning_of_day do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_not_called(record_from_db, :updated_at) do + record_from_db.cache_version + end + + assert_equal record.cache_version, record_from_db.cache_version + end + end + + test "cache_version calls updated_at when the value is generated at create time" do + record = CacheMeWithVersion.create + assert_called(record, :updated_at) do + record.cache_version + end + end + + test "cache_version does NOT call updated_at when value is from the database" do + skip("Mysql2 and PostgreSQL don't return a string value for updated_at") if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_not_called(record_from_db, :updated_at) do + record_from_db.cache_version + end + end + + test "cache_version does call updated_at when it is assigned via a Time object" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_called(record_from_db, :updated_at) do + record_from_db.updated_at = Time.now + record_from_db.cache_version + end + end + + test "cache_version does call updated_at when it is assigned via a string" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_called(record_from_db, :updated_at) do + record_from_db.updated_at = Time.now.to_s + record_from_db.cache_version + end + end + + test "cache_version does call updated_at when it is assigned via a hash" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_called(record_from_db, :updated_at) do + record_from_db.updated_at = { 1 => 2016, 2 => 11, 3 => 12, 4 => 1, 5 => 2, 6 => 3, 7 => 22 } + record_from_db.cache_version + end + end + + test "updated_at on class but not on instance raises an error" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.where(id: record.id).select(:id).first + assert_raises(ActiveModel::MissingAttributeError) do + record_from_db.cache_version + end end end end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index 080d2a54bc..16c2a3661d 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -19,6 +19,7 @@ require "models/developer" require "models/post" require "models/comment" require "models/rating" +require "support/stubs/strong_parameters" class CalculationsTest < ActiveRecord::TestCase fixtures :companies, :accounts, :topics, :speedometers, :minivans, :books, :posts, :comments @@ -218,8 +219,8 @@ class CalculationsTest < ActiveRecord::TestCase Account.select("credit_limit, firm_name").count } - assert_match %r{accounts}i, e.message - assert_match "credit_limit, firm_name", e.message + assert_match %r{accounts}i, e.sql + assert_match "credit_limit, firm_name", e.sql end def test_apply_distinct_in_count @@ -242,6 +243,12 @@ class CalculationsTest < ActiveRecord::TestCase assert_queries(1) { assert_equal 11, posts.count(:all) } end + def test_count_with_eager_loading_and_custom_select_and_order + posts = Post.includes(:comments).order("comments.id").select(:type) + assert_queries(1) { assert_equal 11, posts.count } + assert_queries(1) { assert_equal 11, posts.count(:all) } + end + def test_count_with_eager_loading_and_custom_order_and_distinct posts = Post.includes(:comments).order("comments.id").distinct assert_queries(1) { assert_equal 11, posts.count } @@ -278,6 +285,18 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).offset(2).count end + def test_distinct_joins_count_with_group_by + expected = { nil => 4, 1 => 1, 2 => 1, 4 => 1, 5 => 1, 7 => 1 } + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.count(:author_id) + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.select(:author_id).count + assert_equal expected, Post.left_joins(:comments).group(:post_id).count("DISTINCT posts.author_id") + assert_equal expected, Post.left_joins(:comments).group(:post_id).select("DISTINCT posts.author_id").count + + expected = { nil => 6, 1 => 1, 2 => 1, 4 => 1, 5 => 1, 7 => 1 } + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.count(:all) + assert_equal expected, Post.left_joins(:comments).group(:post_id).distinct.select(:author_id).count(:all) + end + def test_distinct_count_with_group_by_and_order_and_limit assert_equal({ 6 => 2 }, Account.group(:firm_id).distinct.order("1 DESC").limit(1).count) end @@ -344,6 +363,17 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal 60, c[2] end + def test_should_calculate_grouped_with_longer_field + field = "a" * Account.connection.max_identifier_length + + Account.update_all("#{field} = credit_limit") + + c = Account.group(:firm_id).sum(field) + assert_equal 50, c[1] + assert_equal 105, c[6] + assert_equal 60, c[2] + end + def test_should_calculate_with_invalid_field assert_equal 6, Account.calculate(:count, "*") assert_equal 6, Account.calculate(:count, :all) @@ -428,6 +458,8 @@ class CalculationsTest < ActiveRecord::TestCase def test_should_count_selected_field_with_include assert_equal 6, Account.includes(:firm).distinct.count assert_equal 4, Account.includes(:firm).distinct.select(:credit_limit).count + assert_equal 4, Account.includes(:firm).distinct.count("DISTINCT credit_limit") + assert_equal 4, Account.includes(:firm).distinct.count("DISTINCT(credit_limit)") end def test_should_not_perform_joined_include_by_default @@ -458,6 +490,24 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal 6, Account.select("DISTINCT accounts.id").includes(:firm).count end + def test_should_count_manual_select_with_count_all + assert_equal 5, Account.select("DISTINCT accounts.firm_id").count(:all) + end + + def test_should_count_with_manual_distinct_select_and_distinct + assert_equal 4, Account.select("DISTINCT accounts.firm_id").distinct(true).count + end + + def test_should_count_manual_select_with_group_with_count_all + expected = { nil => 1, 1 => 1, 2 => 1, 6 => 2, 9 => 1 } + actual = Account.select("DISTINCT accounts.firm_id").group("accounts.firm_id").count(:all) + assert_equal expected, actual + end + + def test_should_count_manual_with_count_all + assert_equal 6, Account.count(:all) + end + def test_count_selected_arel_attribute assert_equal 5, Account.select(Account.arel_table[:firm_id]).count assert_equal 4, Account.distinct.select(Account.arel_table[:firm_id]).count @@ -509,8 +559,10 @@ class CalculationsTest < ActiveRecord::TestCase end def test_should_count_field_of_root_table_with_conflicting_group_by_column - assert_equal({ 1 => 1 }, Firm.joins(:accounts).group(:firm_id).count) - assert_equal({ 1 => 1 }, Firm.joins(:accounts).group("accounts.firm_id").count) + expected = { 1 => 2, 2 => 1, 4 => 5, 5 => 2, 7 => 1 } + assert_equal expected, Post.joins(:comments).group(:post_id).count + assert_equal expected, Post.joins(:comments).group("comments.post_id").count + assert_equal expected, Post.joins(:comments).group(:post_id).select("DISTINCT posts.author_id").count(:all) end def test_count_with_no_parameters_isnt_deprecated @@ -642,6 +694,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 @@ -676,8 +740,9 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_not_auto_table_name_prefix_if_column_joined - Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) - assert_equal [7], Company.joins(:contracts).pluck(:developer_id) + company = Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) + metadata = company.contracts.first.metadata + assert_equal [metadata], Company.joins(:contracts).pluck(:metadata) end def test_pluck_with_selection_clause @@ -705,6 +770,28 @@ class CalculationsTest < ActiveRecord::TestCase assert_equal [], Topic.includes(:replies).order(:id).offset(5).pluck(:id) end + def test_pluck_with_join + assert_equal [[2, 2], [4, 4]], Reply.includes(:topic).pluck(:id, :"topics.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) @@ -802,13 +889,13 @@ class CalculationsTest < ActiveRecord::TestCase 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) + assert_nil Topic.where(id: 9999999999999999999).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) + assert_nil Topic.where(id: 9999999999999999999).pick(:author_name, :author_email_address) end def test_pick_delegate_to_all @@ -846,26 +933,7 @@ class CalculationsTest < ActiveRecord::TestCase end def test_having_with_strong_parameters - protected_params = Class.new do - attr_reader :permitted - alias :permitted? :permitted - - def initialize(parameters) - @parameters = parameters - @permitted = false - end - - def to_h - @parameters - end - - def permit! - @permitted = true - self - end - end - - params = protected_params.new(credit_limit: "50") + params = ProtectedParams.new(credit_limit: "50") assert_raises(ActiveModel::ForbiddenAttributesError) do Account.group(:id).having(params) @@ -881,15 +949,15 @@ class CalculationsTest < ActiveRecord::TestCase 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 } + def test_count_with_block_and_column_name_raises_an_error + assert_raises(ArgumentError) do + 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 } + def test_sum_with_block_and_column_name_raises_an_error + assert_raises(ArgumentError) do + Account.sum(:firm_id) { 1 } end end diff --git a/activerecord/test/cases/callbacks_test.rb b/activerecord/test/cases/callbacks_test.rb index 3b283a3aa6..4d6a112af5 100644 --- a/activerecord/test/cases/callbacks_test.rb +++ b/activerecord/test/cases/callbacks_test.rb @@ -21,7 +21,7 @@ class CallbackDeveloper < ActiveRecord::Base def callback_object(callback_method) klass = Class.new - klass.send(:define_method, callback_method) do |model| + klass.define_method(callback_method) do |model| model.history << [callback_method, :object] end klass.new @@ -385,9 +385,9 @@ 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 @@ -395,27 +395,27 @@ class CallbacksTest < ActiveRecord::TestCase someone = CallbackHaltedDeveloper.new someone.cancel_before_create = true assert_predicate someone, :valid? - assert !someone.save + assert_not someone.save assert_save_callbacks_not_called(someone) end def test_before_save_throwing_abort david = DeveloperWithCanceledCallbacks.find(1) assert_predicate david, :valid? - assert !david.save + 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_not_predicate david, :valid? - assert !david.save + assert_not david.save assert_raise(ActiveRecord::RecordInvalid) { david.save! } someone = CallbackHaltedDeveloper.find(1) someone.cancel_before_save = true assert_predicate someone, :valid? - assert !someone.save + assert_not someone.save assert_save_callbacks_not_called(someone) end @@ -423,22 +423,22 @@ class CallbacksTest < ActiveRecord::TestCase someone = CallbackHaltedDeveloper.find(1) someone.cancel_before_update = true assert_predicate someone, :valid? - assert !someone.save + 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 @@ -467,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 65e5016040..eea36ee736 100644 --- a/activerecord/test/cases/clone_test.rb +++ b/activerecord/test/cases/clone_test.rb @@ -12,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 @@ -21,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 diff --git a/activerecord/test/cases/collection_cache_key_test.rb b/activerecord/test/cases/collection_cache_key_test.rb index a5d908344a..483383257b 100644 --- a/activerecord/test/cases/collection_cache_key_test.rb +++ b/activerecord/test/cases/collection_cache_key_test.rb @@ -42,6 +42,20 @@ module ActiveRecord assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 end + test "cache_key for relation with custom select and limit" do + developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5) + developers_with_select = developers.select("developers.*") + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers_with_select.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers_with_select.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers_with_select.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 @@ -91,12 +105,12 @@ module ActiveRecord developers = Developer.where(name: "David") assert_queries(1) { developers.cache_key } - assert_queries(0) { developers.cache_key } + assert_no_queries { developers.cache_key } end test "it doesn't trigger any query if the relation is already loaded" do developers = Developer.where(name: "David").load - assert_queries(0) { developers.cache_key } + assert_no_queries { developers.cache_key } end test "relation cache_key changes when the sql query changes" do diff --git a/activerecord/test/cases/connection_adapters/connection_handler_test.rb b/activerecord/test/cases/connection_adapters/connection_handler_test.rb index 57b3a5844e..6282759a10 100644 --- a/activerecord/test/cases/connection_adapters/connection_handler_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_handler_test.rb @@ -28,13 +28,16 @@ module ActiveRecord 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 @@ -89,7 +92,7 @@ module ActiveRecord ActiveRecord::Base.establish_connection - assert_equal "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] + assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] ensure ActiveRecord::Base.configurations = @prev_configs ENV["RAILS_ENV"] = previous_env @@ -112,7 +115,7 @@ module ActiveRecord ActiveRecord::Base.establish_connection - assert_equal "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] + assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] ensure ActiveRecord::Base.configurations = @prev_configs ENV["RAILS_ENV"] = previous_env @@ -148,6 +151,35 @@ module ActiveRecord ActiveRecord::Base.configurations = @prev_configs end + def test_symbolized_configurations_assignment + @prev_configs = ActiveRecord::Base.configurations + config = { + development: { + primary: { + adapter: "sqlite3", + database: "db/development.sqlite3", + }, + }, + test: { + primary: { + adapter: "sqlite3", + database: "db/test.sqlite3", + }, + }, + } + ActiveRecord::Base.configurations = config + ActiveRecord::Base.configurations.configs_for.each do |db_config| + assert_instance_of ActiveRecord::DatabaseConfigurations::HashConfig, db_config + assert_instance_of String, db_config.env_name + assert_instance_of String, db_config.spec_name + db_config.config.keys.each do |key| + assert_instance_of String, key + end + end + ensure + ActiveRecord::Base.configurations = @prev_configs + end + def test_retrieve_connection assert @handler.retrieve_connection(@spec_name) end @@ -350,6 +382,11 @@ module ActiveRecord assert_not_nil ActiveRecord::Base.connection assert_same klass2.connection, ActiveRecord::Base.connection end + + def test_default_handlers_are_writing_and_reading + assert_equal :writing, ActiveRecord::Base.writing_role + assert_equal :reading, ActiveRecord::Base.reading_role + end end end end diff --git a/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb new file mode 100644 index 0000000000..36591097b6 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/person" + +module ActiveRecord + module ConnectionAdapters + class ConnectionHandlersMultiDbTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :people + + def setup + @handlers = { writing: ConnectionHandler.new, reading: ConnectionHandler.new } + @rw_handler = @handlers[:writing] + @ro_handler = @handlers[:reading] + @spec_name = "primary" + @rw_pool = @handlers[:writing].establish_connection(ActiveRecord::Base.configurations["arunit"]) + @ro_pool = @handlers[:reading].establish_connection(ActiveRecord::Base.configurations["arunit"]) + end + + def teardown + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + + class MultiConnectionTestModel < ActiveRecord::Base + end + + def test_multiple_connection_handlers_works_in_a_threaded_environment + tf_writing = Tempfile.open "test_writing" + tf_reading = Tempfile.open "test_reading" + + MultiConnectionTestModel.connects_to database: { writing: { database: tf_writing.path, adapter: "sqlite3" }, reading: { database: tf_reading.path, adapter: "sqlite3" } } + + MultiConnectionTestModel.connection.execute("CREATE TABLE `test_1` (connection_role VARCHAR (255))") + MultiConnectionTestModel.connection.execute("INSERT INTO test_1 VALUES ('writing')") + + ActiveRecord::Base.connected_to(role: :reading) do + MultiConnectionTestModel.connection.execute("CREATE TABLE `test_1` (connection_role VARCHAR (255))") + MultiConnectionTestModel.connection.execute("INSERT INTO test_1 VALUES ('reading')") + end + + read_latch = Concurrent::CountDownLatch.new + write_latch = Concurrent::CountDownLatch.new + + MultiConnectionTestModel.connection + + thread = Thread.new do + MultiConnectionTestModel.connection + + write_latch.wait + assert_equal "writing", MultiConnectionTestModel.connection.select_value("SELECT connection_role from test_1") + read_latch.count_down + end + + ActiveRecord::Base.connected_to(role: :reading) do + write_latch.count_down + assert_equal "reading", MultiConnectionTestModel.connection.select_value("SELECT connection_role from test_1") + read_latch.wait + end + + thread.join + ensure + tf_reading.close + tf_reading.unlink + tf_writing.close + tf_writing.unlink + end + + unless in_memory_db? + def test_establish_connection_using_3_levels_config + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to(database: { writing: :primary, reading: :readonly }) + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:writing].retrieve_connection_pool("primary") + assert_equal "db/primary.sqlite3", pool.spec.config[:database] + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_via_handler + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to(database: { writing: :primary, reading: :readonly }) + + ActiveRecord::Base.connected_to(role: :reading) do + @ro_handler = ActiveRecord::Base.connection_handler + assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:reading] + assert_equal :reading, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :reading) + assert_not ActiveRecord::Base.connected_to?(role: :writing) + end + + ActiveRecord::Base.connected_to(role: :writing) do + assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:writing] + assert_not_equal @ro_handler, ActiveRecord::Base.connection_handler + assert_equal :writing, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :writing) + assert_not ActiveRecord::Base.connected_to?(role: :reading) + end + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_establish_connection_using_3_levels_config_with_non_default_handlers + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to(database: { default: :primary, readonly: :readonly }) + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:default].retrieve_connection_pool("primary") + assert_equal "db/primary.sqlite3", pool.spec.config[:database] + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:readonly].retrieve_connection_pool("primary") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_with_database_url + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + previous_url, ENV["DATABASE_URL"] = ENV["DATABASE_URL"], "postgres://localhost/foo" + + ActiveRecord::Base.connected_to(database: { writing: "postgres://localhost/bar" }) do + assert_equal :writing, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :writing) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:writing] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal({ adapter: "postgresql", database: "bar", host: "localhost" }, pool.spec.config) + end + ensure + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + ENV["DATABASE_URL"] = previous_url + end + + def test_switching_connections_with_database_config_hash + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + config = { adapter: "sqlite3", database: "db/readonly.sqlite3" } + + ActiveRecord::Base.connected_to(database: { writing: config }) do + assert_equal :writing, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :writing) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:writing] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal(config, pool.spec.config) + end + ensure + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_with_database_and_role_raises + error = assert_raises(ArgumentError) do + ActiveRecord::Base.connected_to(database: :readonly, role: :writing) { } + end + assert_equal "connected_to can only accept a `database` or a `role` argument, but not both arguments.", error.message + end + + def test_switching_connections_without_database_and_role_raises + error = assert_raises(ArgumentError) do + ActiveRecord::Base.connected_to { } + end + assert_equal "must provide a `database` or a `role`.", error.message + end + + def test_switching_connections_with_database_symbol + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { adapter: "sqlite3", database: "db/readonly.sqlite3" }, + "primary" => { adapter: "sqlite3", database: "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connected_to(database: :readonly) do + assert_equal :readonly, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :readonly) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:readonly] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal(config["default_env"]["readonly"], pool.spec.config) + end + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_connects_to_with_single_configuration + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to database: { writing: :development } + + assert_equal 1, ActiveRecord::Base.connection_handlers.size + assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:writing] + assert_equal :writing, ActiveRecord::Base.current_role + assert ActiveRecord::Base.connected_to?(role: :writing) + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + end + + def test_connects_to_using_top_level_key_in_two_level_config + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to database: { writing: :development, reading: :development_readonly } + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + end + + def test_connects_to_returns_array_of_established_connections + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + result = ActiveRecord::Base.connects_to database: { writing: :development, reading: :development_readonly } + + assert_equal( + [ + ActiveRecord::Base.connection_handlers[:writing].retrieve_connection_pool("primary"), + ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary") + ], + result + ) + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + end + end + + def test_connection_pools + assert_equal([@rw_pool], @handlers[:writing].connection_pools) + assert_equal([@ro_pool], @handlers[:reading].connection_pools) + end + + def test_retrieve_connection + assert @rw_handler.retrieve_connection(@spec_name) + assert @ro_handler.retrieve_connection(@spec_name) + end + + def test_active_connections? + assert_not_predicate @rw_handler, :active_connections? + assert_not_predicate @ro_handler, :active_connections? + + assert @rw_handler.retrieve_connection(@spec_name) + assert @ro_handler.retrieve_connection(@spec_name) + + assert_predicate @rw_handler, :active_connections? + assert_predicate @ro_handler, :active_connections? + + @rw_handler.clear_active_connections! + assert_not_predicate @rw_handler, :active_connections? + + @ro_handler.clear_active_connections! + assert_not_predicate @ro_handler, :active_connections? + end + + def test_retrieve_connection_pool + assert_not_nil @rw_handler.retrieve_connection_pool(@spec_name) + assert_not_nil @ro_handler.retrieve_connection_pool(@spec_name) + end + + def test_retrieve_connection_pool_with_invalid_id + assert_nil @rw_handler.retrieve_connection_pool("foo") + assert_nil @ro_handler.retrieve_connection_pool("foo") + end + + def test_connection_handlers_are_per_thread_and_not_per_fiber + original_handlers = ActiveRecord::Base.connection_handlers + + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler, reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new } + + reading_handler = ActiveRecord::Base.connection_handlers[:reading] + + reading = ActiveRecord::Base.with_handler(:reading) do + Person.connection_handler + end + + assert_not_equal reading, ActiveRecord::Base.connection_handler + assert_equal reading, reading_handler + ensure + ActiveRecord::Base.connection_handlers = original_handlers + end + + def test_connection_handlers_swapping_connections_in_fiber + original_handlers = ActiveRecord::Base.connection_handlers + + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler, reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new } + + reading_handler = ActiveRecord::Base.connection_handlers[:reading] + + enum = Enumerator.new do |r| + r << ActiveRecord::Base.connection_handler + end + + reading = ActiveRecord::Base.with_handler(:reading) do + enum.next + end + + assert_equal reading, reading_handler + ensure + ActiveRecord::Base.connection_handlers = original_handlers + end + + def test_calling_connected_to_on_a_non_existent_handler_raises + error = assert_raises ActiveRecord::ConnectionNotEstablished do + ActiveRecord::Base.connected_to(role: :reading) do + Person.first + end + end + + assert_equal "No connection pool with 'primary' found for the 'reading' role.", error.message + end + + def test_default_handlers_are_writing_and_reading + assert_equal :writing, ActiveRecord::Base.writing_role + assert_equal :reading, ActiveRecord::Base.reading_role + end + + def test_an_application_can_change_the_default_handlers + old_writing = ActiveRecord::Base.writing_role + old_reading = ActiveRecord::Base.reading_role + ActiveRecord::Base.writing_role = :default + ActiveRecord::Base.reading_role = :readonly + + assert_equal :default, ActiveRecord::Base.writing_role + assert_equal :readonly, ActiveRecord::Base.reading_role + ensure + ActiveRecord::Base.writing_role = old_writing + ActiveRecord::Base.reading_role = old_reading + end + end + end +end 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 1b64324cc4..515bf5df06 100644 --- a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -18,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 @@ -43,6 +46,14 @@ module ActiveRecord assert_equal expected, actual end + def test_resolver_with_nil_database_url_and_current_env + ENV["RAILS_ENV"] = "foo" + config = { "foo" => { "adapter" => "postgres", "url" => ENV["DATABASE_URL"] } } + actual = resolve_spec(:foo, config) + expected = { "adapter" => "postgres", "url" => nil, "name" => "foo" } + assert_equal expected, actual + end + def test_resolver_with_database_uri_and_current_env_symbol_key_and_rack_env ENV["DATABASE_URL"] = "postgres://localhost/foo" ENV["RACK_ENV"] = "foo" @@ -61,6 +72,16 @@ module ActiveRecord assert_equal expected, actual end + def test_resolver_with_database_uri_and_multiple_envs + ENV["DATABASE_URL"] = "postgres://localhost" + ENV["RAILS_ENV"] = "test" + + config = { "production" => { "adapter" => "postgresql", "database" => "foo_prod" }, "test" => { "adapter" => "postgresql", "database" => "foo_test" } } + actual = resolve_spec(:test, config) + expected = { "adapter" => "postgresql", "database" => "foo_test", "host" => "localhost", "name" => "test" } + assert_equal expected, actual + end + def test_resolver_with_database_uri_and_unknown_symbol_key ENV["DATABASE_URL"] = "postgres://localhost/foo" config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } diff --git a/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb index 02e76ce146..38331aa641 100644 --- a/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb +++ b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb @@ -27,8 +27,12 @@ if current_adapter?(:Mysql2Adapter) def test_string_types assert_lookup_type :string, "enum('one', 'two', 'three')" assert_lookup_type :string, "ENUM('one', 'two', 'three')" + assert_lookup_type :string, "enum ('one', 'two', 'three')" + assert_lookup_type :string, "ENUM ('one', 'two', 'three')" assert_lookup_type :string, "set('one', 'two', 'three')" assert_lookup_type :string, "SET('one', 'two', 'three')" + assert_lookup_type :string, "set ('one', 'two', 'three')" + assert_lookup_type :string, "SET ('one', 'two', 'three')" end def test_set_type_with_value_matching_other_type diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index 67496381d1..89a9c30f9b 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -6,8 +6,9 @@ module ActiveRecord module ConnectionAdapters class SchemaCacheTest < ActiveRecord::TestCase def setup - connection = ActiveRecord::Base.connection - @cache = SchemaCache.new connection + @connection = ActiveRecord::Base.connection + @cache = SchemaCache.new @connection + @database_version = @connection.get_database_version end def test_primary_key @@ -19,6 +20,7 @@ module ActiveRecord @cache.columns_hash("posts") @cache.data_sources("posts") @cache.primary_keys("posts") + @cache.indexes("posts") new_cache = YAML.load(YAML.dump(@cache)) assert_no_queries do @@ -26,19 +28,47 @@ module ActiveRecord assert_equal 12, new_cache.columns_hash("posts").size assert new_cache.data_sources("posts") assert_equal "id", new_cache.primary_keys("posts") + assert_equal 1, new_cache.indexes("posts").size + assert_equal @database_version.to_s, new_cache.database_version.to_s end end def test_yaml_loads_5_1_dump - body = File.open(schema_dump_path).read - cache = YAML.load(body) + @cache = YAML.load(File.read(schema_dump_path)) assert_no_queries do - assert_equal 11, cache.columns("posts").size - assert_equal 11, cache.columns_hash("posts").size - assert cache.data_sources("posts") - assert_equal "id", cache.primary_keys("posts") + assert_equal 11, @cache.columns("posts").size + assert_equal 11, @cache.columns_hash("posts").size + assert @cache.data_sources("posts") + assert_equal "id", @cache.primary_keys("posts") + end + end + + def test_yaml_loads_5_1_dump_without_indexes_still_queries_for_indexes + @cache = YAML.load(File.read(schema_dump_path)) + + # Simulate assignment in railtie after loading the cache. + old_cache, @connection.schema_cache = @connection.schema_cache, @cache + + assert_queries :any, ignore_none: true do + assert_equal 1, @cache.indexes("posts").size end + ensure + @connection.schema_cache = old_cache + end + + def test_yaml_loads_5_1_dump_without_database_version_still_queries_for_database_version + @cache = YAML.load(File.read(schema_dump_path)) + + # Simulate assignment in railtie after loading the cache. + old_cache, @connection.schema_cache = @connection.schema_cache, @cache + + # We can't verify queries get executed because the database version gets + # cached in both MySQL and PostgreSQL outside of the schema cache. + assert_nil @cache.instance_variable_get(:@database_version) + assert_equal @database_version.to_s, @cache.database_version.to_s + ensure + @connection.schema_cache = old_cache end def test_primary_key_for_non_existent_table @@ -55,15 +85,30 @@ module ActiveRecord assert_equal columns_hash, @cache.columns_hash("posts") end + def test_caches_indexes + indexes = @cache.indexes("posts") + assert_equal indexes, @cache.indexes("posts") + end + + def test_caches_database_version + @cache.database_version # cache database_version + + assert_no_queries do + assert_equal @database_version.to_s, @cache.database_version.to_s + end + end + def test_clearing @cache.columns("posts") @cache.columns_hash("posts") @cache.data_sources("posts") @cache.primary_keys("posts") + @cache.indexes("posts") @cache.clear! assert_equal 0, @cache.size + assert_nil @cache.instance_variable_get(:@database_version) end def test_dump_and_load @@ -71,6 +116,7 @@ module ActiveRecord @cache.columns_hash("posts") @cache.data_sources("posts") @cache.primary_keys("posts") + @cache.indexes("posts") @cache = Marshal.load(Marshal.dump(@cache)) @@ -79,6 +125,8 @@ module ActiveRecord assert_equal 12, @cache.columns_hash("posts").size assert @cache.data_sources("posts") assert_equal "id", @cache.primary_keys("posts") + assert_equal 1, @cache.indexes("posts").size + assert_equal @database_version.to_s, @cache.database_version.to_s end end @@ -91,8 +139,23 @@ module ActiveRecord @cache.clear_data_source_cache!("posts") end - private + test "#columns_hash? is populated by #columns_hash" do + assert_not @cache.columns_hash?("posts") + + @cache.columns_hash("posts") + assert @cache.columns_hash?("posts") + end + + test "#columns_hash? is not populated by #data_source_exists?" do + assert_not @cache.columns_hash?("posts") + + @cache.data_source_exists?("posts") + + assert_not @cache.columns_hash?("posts") + end + + private def schema_dump_path "test/assets/schema_dump_5_1.yml" end diff --git a/activerecord/test/cases/connection_management_test.rb b/activerecord/test/cases/connection_management_test.rb index 0941ee3309..b9b5cc0e28 100644 --- a/activerecord/test/cases/connection_management_test.rb +++ b/activerecord/test/cases/connection_management_test.rb @@ -106,7 +106,7 @@ module ActiveRecord def middleware(app) lambda do |env| a, b, c = executor.wrap { app.call(env) } - [a, b, Rack::BodyProxy.new(c) {}] + [a, b, Rack::BodyProxy.new(c) { }] end end end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 9ac03629c3..a15ad9a45b 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -91,7 +91,9 @@ module ActiveRecord end def test_full_pool_exception + @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 @@ -109,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 } @@ -156,6 +196,48 @@ 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 @@ -166,9 +248,10 @@ module ActiveRecord assert_equal 3, @pool.connections.length - def idle_conn.seconds_idle - 1000 - end + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 1000 + ) @pool.flush(30) @@ -484,23 +567,21 @@ module ActiveRecord def test_disconnect_and_clear_reloadable_connections_attempt_to_wait_for_threads_to_return_their_conns [:disconnect, :disconnect!, :clear_reloadable_connections, :clear_reloadable_connections!].each do |group_action_method| - begin - thread = timed_join_result = nil - @pool.with_connection do |connection| - thread = Thread.new { @pool.send(group_action_method) } - - # give the other `thread` some time to get stuck in `group_action_method` - timed_join_result = thread.join(0.3) - # thread.join # => `nil` means the other thread hasn't finished running and is still waiting for us to - # release our connection - assert_nil timed_join_result - - # assert that since this is within default timeout our connection hasn't been forcefully taken away from us - assert_predicate @pool, :active_connection? - end - ensure - thread.join if thread && !timed_join_result # clean up the other thread + thread = timed_join_result = nil + @pool.with_connection do |connection| + thread = Thread.new { @pool.send(group_action_method) } + + # give the other `thread` some time to get stuck in `group_action_method` + timed_join_result = thread.join(0.3) + # thread.join # => `nil` means the other thread hasn't finished running and is still waiting for us to + # release our connection + assert_nil timed_join_result + + # assert that since this is within default timeout our connection hasn't been forcefully taken away from us + assert_predicate @pool, :active_connection? end + ensure + thread.join if thread && !timed_join_result # clean up the other thread end end @@ -578,7 +659,7 @@ module ActiveRecord end stuck_thread = Thread.new do - pool.with_connection {} + pool.with_connection { } end # wait for stuck_thread to get in queue diff --git a/activerecord/test/cases/connection_specification/resolver_test.rb b/activerecord/test/cases/connection_specification/resolver_test.rb index 5b80f16a44..72be14f507 100644 --- a/activerecord/test/cases/connection_specification/resolver_test.rb +++ b/activerecord/test/cases/connection_specification/resolver_test.rb @@ -7,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 diff --git a/activerecord/test/cases/core_test.rb b/activerecord/test/cases/core_test.rb index 6e7ae2efb4..36e3d543cd 100644 --- a/activerecord/test/cases/core_test.rb +++ b/activerecord/test/cases/core_test.rb @@ -30,13 +30,18 @@ class CoreTest < ActiveRecord::TestCase assert_equal %(#<Topic id: 1, title: "The First Topic">), Topic.all.merge!(select: "id, title", where: "id = 1").first.inspect end + def test_inspect_instance_with_non_primary_key_id_attribute + topic = topics(:first).becomes(TitlePrimaryKeyTopic) + assert_match(/id: 1/, topic.inspect) + end + def test_inspect_class_without_table assert_equal "NonExistentTable(Table doesn't exist)", NonExistentTable.inspect end def test_pretty_print_new topic = Topic.new - actual = "".dup + actual = +"" PP.pp(topic, StringIO.new(actual)) expected = <<~PRETTY #<Topic:0xXXXXXX @@ -65,7 +70,7 @@ class CoreTest < ActiveRecord::TestCase def test_pretty_print_persisted topic = topics(:first) - actual = "".dup + actual = +"" PP.pp(topic, StringIO.new(actual)) expected = <<~PRETTY #<Topic:0x\\w+ @@ -93,7 +98,7 @@ class CoreTest < ActiveRecord::TestCase def test_pretty_print_uninitialized topic = Topic.allocate - actual = "".dup + actual = +"" PP.pp(topic, StringIO.new(actual)) expected = "#<Topic:XXXXXX not initialized>\n" assert actual.start_with?(expected.split("XXXXXX").first) @@ -106,8 +111,15 @@ class CoreTest < ActiveRecord::TestCase "inspecting topic" end end - actual = "".dup + actual = +"" PP.pp(subtopic.new, StringIO.new(actual)) assert_equal "inspecting topic\n", actual end + + def test_pretty_print_with_non_primary_key_id_attribute + topic = topics(:first).becomes(TitlePrimaryKeyTopic) + actual = +"" + PP.pp(topic, StringIO.new(actual)) + assert_match(/id: 1/, actual) + end end diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb index e0948f90ac..cc4f86a0fb 100644 --- a/activerecord/test/cases/counter_cache_test.rb +++ b/activerecord/test/cases/counter_cache_test.rb @@ -144,7 +144,7 @@ class CounterCacheTest < ActiveRecord::TestCase test "update other counters on parent destroy" do david, joanna = dog_lovers(:david, :joanna) - joanna = joanna # squelch a warning + _ = joanna # squelch a warning assert_difference "joanna.reload.dogs_count", -1 do david.destroy @@ -280,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/database_configurations_test.rb b/activerecord/test/cases/database_configurations_test.rb new file mode 100644 index 0000000000..ed8151f01a --- /dev/null +++ b/activerecord/test/cases/database_configurations_test.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "cases/helper" + +class DatabaseConfigurationsTest < ActiveRecord::TestCase + unless in_memory_db? + def test_empty_returns_true_when_db_configs_are_empty + old_config = ActiveRecord::Base.configurations + config = {} + + ActiveRecord::Base.configurations = config + + assert_predicate ActiveRecord::Base.configurations, :empty? + assert_predicate ActiveRecord::Base.configurations, :blank? + ensure + ActiveRecord::Base.configurations = old_config + ActiveRecord::Base.establish_connection :arunit + end + end + + def test_configs_for_getter_with_env_name + configs = ActiveRecord::Base.configurations.configs_for(env_name: "arunit") + + assert_equal 1, configs.size + assert_equal ["arunit"], configs.map(&:env_name) + end + + def test_configs_for_getter_with_env_and_spec_name + config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", spec_name: "primary") + + assert_equal "arunit", config.env_name + assert_equal "primary", config.spec_name + end + + def test_default_hash_returns_config_hash_from_default_env + original_rails_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "arunit" + + assert_equal ActiveRecord::Base.configurations.configs_for(env_name: "arunit", spec_name: "primary").config, ActiveRecord::Base.configurations.default_hash + ensure + ENV["RAILS_ENV"] = original_rails_env + end + + def test_find_db_config_returns_a_db_config_object_for_the_given_env + config = ActiveRecord::Base.configurations.find_db_config("arunit2") + + assert_equal "arunit2", config.env_name + assert_equal "primary", config.spec_name + end + + def test_to_h_turns_db_config_object_back_into_a_hash + configs = ActiveRecord::Base.configurations + assert_equal "ActiveRecord::DatabaseConfigurations", configs.class.name + assert_equal "Hash", configs.to_h.class.name + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"], ActiveRecord::Base.configurations.to_h.keys.sort + end +end + +class LegacyDatabaseConfigurationsTest < ActiveRecord::TestCase + unless in_memory_db? + def test_setting_configurations_hash + old_config = ActiveRecord::Base.configurations + config = { "adapter" => "sqlite3" } + + assert_deprecated do + ActiveRecord::Base.configurations["readonly"] = config + end + + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements", "readonly"], ActiveRecord::Base.configurations.configs_for.map(&:env_name).sort + ensure + ActiveRecord::Base.configurations = old_config + ActiveRecord::Base.establish_connection :arunit + end + end + + def test_can_turn_configurations_into_a_hash + assert ActiveRecord::Base.configurations.to_h.is_a?(Hash), "expected to be a hash but was not." + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"].sort, ActiveRecord::Base.configurations.to_h.keys.sort + end + + def test_each_is_deprecated + assert_deprecated do + ActiveRecord::Base.configurations.each do |db_config| + assert_equal "primary", db_config.spec_name + end + end + end + + def test_first_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_fetch_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.fetch("arunit").first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_values_are_deprecated + config_hashes = ActiveRecord::Base.configurations.configurations.map(&:config) + assert_deprecated do + assert_equal config_hashes, ActiveRecord::Base.configurations.values + end + end + + def test_unsupported_method_raises + assert_raises NotImplementedError do + ActiveRecord::Base.configurations.select { |a| a == "foo" } + end + end +end diff --git a/activerecord/test/cases/database_selector_test.rb b/activerecord/test/cases/database_selector_test.rb new file mode 100644 index 0000000000..fd02d2acb4 --- /dev/null +++ b/activerecord/test/cases/database_selector_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/person" +require "action_dispatch" + +module ActiveRecord + class DatabaseSelectorTest < ActiveRecord::TestCase + setup do + @session_store = {} + @session = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session.new(@session_store) + end + + teardown do + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + + def test_empty_session + assert_equal Time.at(0), @session.last_write_timestamp + end + + def test_writing_the_session_timestamps + assert @session.update_last_write_timestamp + + session2 = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session.new(@session_store) + assert_equal @session.last_write_timestamp, session2.last_write_timestamp + end + + def test_writing_session_time_changes + assert @session.update_last_write_timestamp + + before = @session.last_write_timestamp + sleep(0.1) + + assert @session.update_last_write_timestamp + assert_not_equal before, @session.last_write_timestamp + end + + def test_read_from_replicas + @session_store[:last_write] = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session.convert_time_to_timestamp(Time.now - 5.seconds) + + resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver.new(@session) + + called = false + resolver.read do + called = true + assert ActiveRecord::Base.connected_to?(role: :reading) + end + assert called + end + + def test_read_from_primary + @session_store[:last_write] = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session.convert_time_to_timestamp(Time.now) + + resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver.new(@session) + + called = false + resolver.read do + called = true + assert ActiveRecord::Base.connected_to?(role: :writing) + end + assert called + end + + def test_write_to_primary + resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver.new(@session) + + # Session should start empty + assert_nil @session_store[:last_write] + + called = false + resolver.write do + assert ActiveRecord::Base.connected_to?(role: :writing) + called = true + end + assert called + + # and be populated by the last write time + assert @session_store[:last_write] + end + + def test_write_to_primary_with_exception + resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver.new(@session) + + # Session should start empty + assert_nil @session_store[:last_write] + + called = false + assert_raises(ActiveRecord::RecordNotFound) do + resolver.write do + assert ActiveRecord::Base.connected_to?(role: :writing) + called = true + raise ActiveRecord::RecordNotFound + end + end + assert called + + # and be populated by the last write time + assert @session_store[:last_write] + end + + def test_read_from_primary_with_options + resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver.new(@session, delay: 5.seconds) + + # Session should start empty + assert_nil @session_store[:last_write] + + called = false + resolver.write do + assert ActiveRecord::Base.connected_to?(role: :writing) + called = true + end + assert called + + # and be populated by the last write time + assert @session_store[:last_write] + + read = false + resolver.read do + assert ActiveRecord::Base.connected_to?(role: :writing) + read = true + end + assert read + end + + def test_read_from_replica_with_no_delay + resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver.new(@session, delay: 0.seconds) + + # Session should start empty + assert_nil @session_store[:last_write] + + called = false + resolver.write do + assert ActiveRecord::Base.connected_to?(role: :writing) + called = true + end + assert called + + # and be populated by the last write time + assert @session_store[:last_write] + + read = false + resolver.read do + assert ActiveRecord::Base.connected_to?(role: :reading) + read = true + end + assert read + end + + def test_the_middleware_chooses_writing_role_with_POST_request + middleware = ActiveRecord::Middleware::DatabaseSelector.new(lambda { |env| + assert ActiveRecord::Base.connected_to?(role: :writing) + [200, {}, ["body"]] + }) + assert_equal [200, {}, ["body"]], middleware.call("REQUEST_METHOD" => "POST") + end + + def test_the_middleware_chooses_reading_role_with_GET_request + middleware = ActiveRecord::Middleware::DatabaseSelector.new(lambda { |env| + assert ActiveRecord::Base.connected_to?(role: :reading) + [200, {}, ["body"]] + }) + assert_equal [200, {}, ["body"]], middleware.call("REQUEST_METHOD" => "GET") + end + end +end diff --git a/activerecord/test/cases/date_test.rb b/activerecord/test/cases/date_test.rb index 9f412cdb63..2475d4a34b 100644 --- a/activerecord/test/cases/date_test.rb +++ b/activerecord/test/cases/date_test.rb @@ -23,23 +23,13 @@ class DateTest < ActiveRecord::TestCase valid_dates.each do |date_src| topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s) - # Oracle DATE columns are datetime columns and Oracle adapter returns Time value - if current_adapter?(:OracleAdapter) - assert_equal(topic.last_read.to_date, Date.new(*date_src)) - else - assert_equal(topic.last_read, Date.new(*date_src)) - end + assert_equal(topic.last_read, Date.new(*date_src)) end invalid_dates.each do |date_src| assert_nothing_raised do topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s) - # Oracle DATE columns are datetime columns and Oracle adapter returns Time value - if current_adapter?(:OracleAdapter) - assert_equal(topic.last_read.to_date, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") - else - assert_equal(topic.last_read, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") - end + assert_equal(topic.last_read, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") end end end diff --git a/activerecord/test/cases/date_time_precision_test.rb b/activerecord/test/cases/date_time_precision_test.rb index e64a8372d0..79d63949ca 100644 --- a/activerecord/test/cases/date_time_precision_test.rb +++ b/activerecord/test/cases/date_time_precision_test.rb @@ -29,7 +29,7 @@ if subsecond_precision_supported? def test_datetime_precision_is_truncated_on_assignment @connection.create_table(:foos, force: true) - @connection.add_column :foos, :created_at, :datetime, precision: 0 + @connection.add_column :foos, :created_at, :datetime, precision: 0 @connection.add_column :foos, :updated_at, :datetime, precision: 6 time = ::Time.now.change(nsec: 123456789) @@ -45,6 +45,26 @@ if subsecond_precision_supported? assert_equal 123456000, foo.updated_at.nsec end + unless current_adapter?(:Mysql2Adapter) + def test_no_datetime_precision_isnt_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :created_at, :datetime + @connection.add_column :foos, :updated_at, :datetime, precision: 6 + + time = ::Time.now.change(nsec: 123) + foo = Foo.new(created_at: time, updated_at: time) + + assert_equal 123, foo.created_at.nsec + assert_equal 0, foo.updated_at.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.created_at.nsec + assert_equal 0, foo.updated_at.nsec + end + end + def test_timestamps_helper_with_custom_precision @connection.create_table(:foos, force: true) do |t| t.timestamps precision: 4 @@ -62,7 +82,7 @@ if subsecond_precision_supported? end def test_invalid_datetime_precision_raises_error - assert_raises ActiveRecord::ActiveRecordError do + assert_raises ArgumentError do @connection.create_table(:foos, force: true) do |t| t.timestamps precision: 7 end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb index 3d11b573f1..50a86b0a19 100644 --- a/activerecord/test/cases/defaults_test.rb +++ b/activerecord/test/cases/defaults_test.rb @@ -9,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 @@ -89,7 +89,7 @@ if current_adapter?(:PostgreSQLAdapter) test "schema dump includes default expression" do output = dump_table_schema("defaults") - if ActiveRecord::Base.connection.postgresql_version >= 100000 + if ActiveRecord::Base.connection.database_version >= 100000 assert_match %r/t\.date\s+"modified_date",\s+default: -> { "CURRENT_DATE" }/, output assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "CURRENT_TIMESTAMP" }/, output else @@ -106,21 +106,38 @@ if current_adapter?(:Mysql2Adapter) class MysqlDefaultExpressionTest < ActiveRecord::TestCase include SchemaDumpingHelper - if ActiveRecord::Base.connection.version >= "5.6.0" + if supports_default_expression? + test "schema dump includes default expression" do + output = dump_table_schema("defaults") + assert_match %r/t\.binary\s+"uuid",\s+limit: 36,\s+default: -> { "\(uuid\(\)\)" }/i, output + end + end + + 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 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 without default expression" do - output = dump_table_schema("timestamp_defaults") - assert_match %r/t\.timestamp\s+"nullable_timestamp"$/, output + 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 5c7f70b7a0..a2a501a794 100644 --- a/activerecord/test/cases/dirty_test.rb +++ b/activerecord/test/cases/dirty_test.rb @@ -336,7 +336,7 @@ class DirtyTest < ActiveRecord::TestCase end with_partial_writes Pirate, true do - assert_queries(0) { 2.times { pirate.save! } } + assert_no_queries { 2.times { pirate.save! } } assert_equal old_updated_on, pirate.reload.updated_on assert_queries(1) { pirate.catchphrase = "bar"; pirate.save! } @@ -352,10 +352,10 @@ class DirtyTest < ActiveRecord::TestCase Person.where(id: person.id).update_all(first_name: "baz") end - old_lock_version = person.lock_version + old_lock_version = person.lock_version + 1 with_partial_writes Person, true do - assert_queries(0) { 2.times { person.save! } } + assert_no_queries { 2.times { person.save! } } assert_equal old_lock_version, person.reload.lock_version assert_queries(1) { person.first_name = "bar"; person.save! } @@ -366,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 @@ -496,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 @@ -510,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 @@ -527,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!") @@ -541,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) @@ -553,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) @@ -565,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 @@ -879,6 +877,26 @@ class DirtyTest < ActiveRecord::TestCase 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 diff --git a/activerecord/test/cases/dup_test.rb b/activerecord/test/cases/dup_test.rb index 9e33c3110c..a2efbf89f9 100644 --- a/activerecord/test/cases/dup_test.rb +++ b/activerecord/test/cases/dup_test.rb @@ -17,7 +17,7 @@ module ActiveRecord 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 @@ -32,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 @@ -140,7 +140,7 @@ 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 @@ -171,7 +171,7 @@ module ActiveRecord end end - assert !movie.persisted? + assert_not movie.persisted? end end end diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb index d5a1d11e12..ae0ce195b3 100644 --- a/activerecord/test/cases/enum_test.rb +++ b/activerecord/test/cases/enum_test.rb @@ -44,6 +44,11 @@ class EnumTest < ActiveRecord::TestCase assert_equal books(:rfr), authors(:david).unpublished_books.first end + test "find via negative scope" do + assert Book.not_published.exclude?(@book) + assert Book.not_proposed.include?(@book) + end + test "find via where with values" do published, written = Book.statuses[:published], Book.statuses[:written] @@ -265,6 +270,35 @@ class EnumTest < ActiveRecord::TestCase assert_equal "published", @book.status end + test "invalid definition values raise an ArgumentError" do + e = assert_raises(ArgumentError) do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [proposed: 1, written: 2, published: 3] + end + end + + assert_match(/must be either a hash, an array of symbols, or an array of strings./, e.message) + + e = assert_raises(ArgumentError) do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: { "" => 1, "active" => 2 } + end + end + + assert_match(/Enum label name must not be blank/, e.message) + + e = assert_raises(ArgumentError) do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: ["active", ""] + end + end + + assert_match(/Enum label name must not be blank/, e.message) + end + test "reserved enum names" do klass = Class.new(ActiveRecord::Base) do self.table_name = "books" @@ -409,6 +443,20 @@ class EnumTest < ActiveRecord::TestCase assert_equal ["drafted", "uploaded"], book2.status_change end + test "attempting to modify enum raises error" do + e = assert_raises(RuntimeError) do + Book.statuses["bad_enum"] = 40 + end + + assert_match(/can't modify frozen/, e.message) + + e = assert_raises(RuntimeError) do + Book.statuses.delete("published") + end + + assert_match(/can't modify frozen/, e.message) + end + test "declare multiple enums at a time" do klass = Class.new(ActiveRecord::Base) do self.table_name = "books" @@ -508,4 +556,13 @@ class EnumTest < ActiveRecord::TestCase test "data type of Enum type" do assert_equal :integer, Book.type_for_attribute("status").type end + + test "scopes can be disabled" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:proposed, :written], _scopes: false + end + + assert_raises(NoMethodError) { klass.proposed } + end end diff --git a/activerecord/test/cases/errors_test.rb b/activerecord/test/cases/errors_test.rb index b90e6a66c5..0d2be944b5 100644 --- a/activerecord/test/cases/errors_test.rb +++ b/activerecord/test/cases/errors_test.rb @@ -8,11 +8,9 @@ class ErrorsTest < ActiveRecord::TestCase error_klasses = ObjectSpace.each_object(Class).select { |klass| klass < base } (error_klasses - [ActiveRecord::AmbiguousSourceReflectionForThroughAssociation]).each do |error_klass| - begin - error_klass.new.inspect - rescue ArgumentError - raise "Instance of #{error_klass} can't be initialized with no arguments" - end + error_klass.new.inspect + rescue ArgumentError + raise "Instance of #{error_klass} can't be initialized with no arguments" end end end diff --git a/activerecord/test/cases/explain_subscriber_test.rb b/activerecord/test/cases/explain_subscriber_test.rb index 82cc891970..79a0630193 100644 --- a/activerecord/test/cases/explain_subscriber_test.rb +++ b/activerecord/test/cases/explain_subscriber_test.rb @@ -40,7 +40,7 @@ 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_empty queries end diff --git a/activerecord/test/cases/filter_attributes_test.rb b/activerecord/test/cases/filter_attributes_test.rb new file mode 100644 index 0000000000..2f4c9b0ef7 --- /dev/null +++ b/activerecord/test/cases/filter_attributes_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/admin" +require "models/admin/user" +require "models/admin/account" +require "models/user" +require "pp" + +class FilterAttributesTest < ActiveRecord::TestCase + fixtures :"admin/users", :"admin/accounts" + + setup do + @previous_filter_attributes = ActiveRecord::Base.filter_attributes + ActiveRecord::Base.filter_attributes = [:name] + end + + teardown do + ActiveRecord::Base.filter_attributes = @previous_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 "string filter_attributes perform pertial match" do + ActiveRecord::Base.filter_attributes = ["n"] + Admin::Account.all.each do |account| + assert_includes account.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + end + + test "regex filter_attributes are accepted" do + ActiveRecord::Base.filter_attributes = [/\An\z/] + account = Admin::Account.find_by(name: "37signals") + assert_includes account.inspect, 'name: "37signals"' + assert_equal 0, account.inspect.scan("[FILTERED]").length + + ActiveRecord::Base.filter_attributes = [/\An/] + account = Admin::Account.find_by(name: "37signals") + assert_includes account.reload.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + + test "proc filter_attributes are accepted" do + ActiveRecord::Base.filter_attributes = [ lambda { |key, value| value.reverse! if key == "name" } ] + account = Admin::Account.find_by(name: "37signals") + assert_includes account.inspect, 'name: "slangis73"' + 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 + + begin + 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 + ensure + Admin::Account.remove_instance_variable(:@filter_attributes) + end + 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 should handle [FILTERED] value properly" do + User.filter_attributes = ["auth"] + user = User.new(token: "[FILTERED]", auth_token: "[FILTERED]") + + assert_includes user.inspect, "auth_token: [FILTERED]" + assert_includes user.inspect, 'token: "[FILTERED]"' + ensure + User.remove_instance_variable(:@filter_attributes) + 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 + + test "filter_attributes on pretty_print should handle [FILTERED] value properly" do + User.filter_attributes = ["auth"] + user = User.new(token: "[FILTERED]", auth_token: "[FILTERED]") + actual = "".dup + PP.pp(user, StringIO.new(actual)) + + assert_includes actual, "auth_token: [FILTERED]" + assert_includes actual, 'token: "[FILTERED]"' + ensure + User.remove_instance_variable(:@filter_attributes) + end +end diff --git a/activerecord/test/cases/finder_respond_to_test.rb b/activerecord/test/cases/finder_respond_to_test.rb index 59af4e6961..66413a98e4 100644 --- a/activerecord/test/cases/finder_respond_to_test.rb +++ b/activerecord/test/cases/finder_respond_to_test.rb @@ -12,10 +12,10 @@ class FinderRespondToTest < ActiveRecord::TestCase end def test_should_preserve_normal_respond_to_behaviour_and_respond_to_newly_added_method - class << Topic; self; end.send(:define_method, :method_added_for_finder_respond_to_test) {} + Topic.singleton_class.define_method(:method_added_for_finder_respond_to_test) { } assert_respond_to Topic, :method_added_for_finder_respond_to_test ensure - class << Topic; self; end.send(:remove_method, :method_added_for_finder_respond_to_test) + Topic.singleton_class.remove_method :method_added_for_finder_respond_to_test end def test_should_preserve_normal_respond_to_behaviour_and_respond_to_standard_object_method @@ -56,6 +56,6 @@ class FinderRespondToTest < ActiveRecord::TestCase private def ensure_topic_method_is_not_cached(method_id) - class << Topic; self; end.send(:remove_method, method_id) if Topic.public_methods.include? method_id + Topic.singleton_class.remove_method method_id if Topic.public_methods.include? method_id end end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index d85910d9c6..ca114d468e 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -3,6 +3,7 @@ require "cases/helper" require "models/post" require "models/author" +require "models/account" require "models/categorization" require "models/comment" require "models/company" @@ -20,6 +21,8 @@ require "models/matey" require "models/dog" require "models/car" require "models/tyre" +require "models/subscriber" +require "support/stubs/strong_parameters" class FinderTest < ActiveRecord::TestCase fixtures :companies, :topics, :entrants, :developers, :developers_projects, :posts, :comments, :accounts, :authors, :author_addresses, :customers, :categories, :categorizations, :cars @@ -167,6 +170,7 @@ class FinderTest < ActiveRecord::TestCase assert_equal true, Topic.exists?(id: [1, 9999]) assert_equal false, Topic.exists?(45) + assert_equal false, Topic.exists?(9999999999999999999999999999999) assert_equal false, Topic.exists?(Topic.new.id) assert_raise(NoMethodError) { Topic.exists?([1, 2]) } @@ -211,17 +215,35 @@ class FinderTest < ActiveRecord::TestCase assert_equal false, relation.exists?(false) end + def test_exists_with_string + assert_equal false, Subscriber.exists?("foo") + assert_equal false, Subscriber.exists?(" ") + + Subscriber.create!(id: "foo") + Subscriber.create!(id: " ") + + assert_equal true, Subscriber.exists?("foo") + assert_equal true, Subscriber.exists?(" ") + end + + def test_exists_with_strong_parameters + assert_equal false, Subscriber.exists?(ProtectedParams.new(nick: "foo").permit!) + + Subscriber.create!(nick: "foo") + + assert_equal true, Subscriber.exists?(ProtectedParams.new(nick: "foo").permit!) + + assert_raises(ActiveModel::ForbiddenAttributesError) do + Subscriber.exists?(ProtectedParams.new(nick: "foo")) + end + end + def test_exists_passing_active_record_object_is_not_permitted assert_raises(ArgumentError) do Topic.exists?(Topic.new) end end - def test_exists_returns_false_when_parameter_has_invalid_type - assert_equal false, Topic.exists?("foo") - assert_equal false, Topic.exists?(("9" * 53).to_i) # number that's bigger than int - end - def test_exists_does_not_select_columns_without_alias assert_sql(/SELECT\W+1 AS one FROM ["`]topics["`]/i) do Topic.exists? @@ -246,6 +268,20 @@ class FinderTest < ActiveRecord::TestCase assert_equal true, Topic.first.replies.exists? end + def test_exists_with_empty_hash_arg + assert_equal true, Topic.exists?({}) + end + + def test_exists_with_distinct_and_offset_and_joins + assert Post.left_joins(:comments).distinct.offset(10).exists? + assert_not Post.left_joins(:comments).distinct.offset(11).exists? + end + + def test_exists_with_distinct_and_offset_and_select + assert Post.select(:body).distinct.offset(3).exists? + assert_not Post.select(:body).distinct.offset(4).exists? + end + # 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 @@ -257,6 +293,17 @@ class FinderTest < ActiveRecord::TestCase assert_equal true, Topic.order(Arel.sql("invalid sql here")).exists? end + def test_exists_with_large_number + assert_equal true, Topic.where(id: [1, 9223372036854775808]).exists? + assert_equal true, Topic.where(id: 1..9223372036854775808).exists? + assert_equal true, Topic.where(id: -9223372036854775809..9223372036854775808).exists? + assert_equal false, Topic.where(id: 9223372036854775808..9223372036854775809).exists? + assert_equal false, Topic.where(id: -9223372036854775810..-9223372036854775809).exists? + assert_equal false, Topic.where(id: 9223372036854775808..1).exists? + assert_equal true, Topic.where(id: 1).or(Topic.where(id: 9223372036854775808)).exists? + assert_equal true, Topic.where.not(id: 9223372036854775808).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 @@ -355,17 +402,29 @@ 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 + assert_equal topics(:first), Topic.where(id: [1, 9999999999999999999999999999999]).find(1) + end + + def test_find_by_on_relation_with_large_number assert_nil Topic.where("1=1").find_by(id: 9999999999999999999999999999999) + assert_equal topics(:first), Topic.where(id: [1, 9999999999999999999999999999999]).find_by(id: 1) end def test_find_by_bang_on_relation_with_large_number assert_raises(ActiveRecord::RecordNotFound) do Topic.where("1=1").find_by!(id: 9999999999999999999999999999999) end + assert_equal topics(:first), Topic.where(id: [1, 9999999999999999999999999999999]).find_by!(id: 1) end def test_find_an_empty_array - assert_equal [], Topic.find([]) + empty_array = [] + result = Topic.find(empty_array) + assert_equal [], result + assert_not_same empty_array, result end def test_find_doesnt_have_implicit_ordering @@ -403,18 +462,18 @@ class FinderTest < ActiveRecord::TestCase end def test_find_by_association_subquery - author = authors(:david) - assert_equal author.post, Post.find_by(author: Author.where(id: author)) - assert_equal author.post, Post.find_by(author_id: Author.where(id: author)) + firm = companies(:first_firm) + assert_equal firm.account, Account.find_by(firm: Firm.where(id: firm)) + assert_equal firm.account, Account.find_by(firm_id: Firm.where(id: firm)) end def test_find_by_and_where_consistency_with_active_record_instance - author = authors(:david) - assert_equal Post.where(author_id: author).take, Post.find_by(author_id: author) + firm = companies(:first_firm) + assert_equal Account.where(firm_id: firm).take, Account.find_by(firm_id: firm) end def test_take - assert_equal topics(:first), Topic.take + assert_equal topics(:first), Topic.where("title = 'The First Topic'").take end def test_take_failing @@ -457,6 +516,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 @@ -479,6 +539,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 @@ -501,6 +562,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 @@ -523,6 +585,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 @@ -545,6 +608,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 @@ -707,6 +771,24 @@ class FinderTest < ActiveRecord::TestCase 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_implicit_order_column_is_configurable + old_implicit_order_column = Topic.implicit_order_column + Topic.implicit_order_column = "title" + + assert_equal topics(:fifth), Topic.first + assert_equal topics(:third), Topic.last + ensure + Topic.implicit_order_column = old_implicit_order_column + 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) @@ -727,8 +809,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 @@ -894,6 +976,7 @@ class FinderTest < ActiveRecord::TestCase 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) + assert_equal Customer.where(balance: [david_balance.amount, zaphod_balance.amount]).to_sql, found_customers.to_sql end def test_hash_condition_find_with_aggregate_attribute_having_same_name_as_field_and_key_value_being_aggregate @@ -931,6 +1014,24 @@ class FinderTest < ActiveRecord::TestCase assert_equal customers(:david), found_customer end + def test_hash_condition_find_nil_with_aggregate_having_one_mapping + assert_nil customers(:zaphod).gps_location + found_customer = Customer.where(gps_location: nil, name: customers(:zaphod).name).first + assert_equal customers(:zaphod), found_customer + end + + def test_hash_condition_find_nil_with_aggregate_having_multiple_mappings + customers(:david).update(address: nil) + assert_nil customers(:david).address_street + assert_nil customers(:david).address_city + found_customer = Customer.where(address: nil, name: customers(:david).name).first + assert_equal customers(:david), found_customer + end + + def test_hash_condition_find_empty_array_with_aggregate_having_multiple_mappings + assert_nil Customer.where(address: []).first + end + def test_condition_utc_time_interpolation_with_default_timezone_local with_env_tz "America/New_York" do with_timezone_config default: :local do @@ -1072,7 +1173,7 @@ class FinderTest < ActiveRecord::TestCase def test_dynamic_finder_on_one_attribute_with_conditions_returns_same_results_after_caching # ensure this test can run independently of order - class << Account; self; end.send(:remove_method, :find_by_credit_limit) if Account.public_methods.include?(:find_by_credit_limit) + Account.singleton_class.remove_method :find_by_credit_limit if Account.public_methods.include?(:find_by_credit_limit) a = Account.where("firm_id = ?", 6).find_by_credit_limit(50) assert_equal a, Account.where("firm_id = ?", 6).find_by_credit_limit(50) # find_by_credit_limit has been cached end diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb index 184b750161..0cb868da6e 100644 --- a/activerecord/test/cases/fixtures_test.rb +++ b/activerecord/test/cases/fixtures_test.rb @@ -1,6 +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" @@ -32,6 +33,8 @@ require "models/treasure" require "tempfile" class FixturesTest < ActiveRecord::TestCase + include ConnectionHelper + self.use_instantiated_fixtures = true self.use_transactional_tests = false @@ -70,14 +73,12 @@ class FixturesTest < ActiveRecord::TestCase 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 + 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 def test_bulk_insert_multiple_table_with_a_multi_statement_query @@ -114,24 +115,111 @@ class FixturesTest < ActiveRecord::TestCase 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 + bytes_needed_to_have_a_1024_bytes_fixture = 906 fixtures = { "traffic_lights" => [ { "location" => "US", "state" => ["NY"], "long_state" => ["a" * bytes_needed_to_have_a_1024_bytes_fixture] }, ] } - conn.stubs(:max_allowed_packet).returns(packet_size - mysql_margin) - - error = assert_raises(ActiveRecord::ActiveRecordError) { conn.insert_fixtures_set(fixtures) } - assert_match(/Fixtures set is too large #{packet_size}\./, error.message) + 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 @@ -143,10 +231,10 @@ class FixturesTest < ActiveRecord::TestCase ] } - conn.stubs(:max_allowed_packet).returns(packet_size) - - assert_difference "TrafficLight.count" do - conn.insert_fixtures_set(fixtures) + conn.stub(:max_allowed_packet, packet_size) do + assert_difference "TrafficLight.count" do + conn.insert_fixtures_set(fixtures) + end end end @@ -164,12 +252,13 @@ class FixturesTest < ActiveRecord::TestCase ] } - conn.stubs(:max_allowed_packet).returns(packet_size) + conn.stub(:max_allowed_packet, packet_size) do + conn.insert_fixtures_set(fixtures) - 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 + 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 @@ -188,10 +277,10 @@ class FixturesTest < ActiveRecord::TestCase ] } - conn.stubs(:max_allowed_packet).returns(packet_size) - - assert_difference ["TrafficLight.count", "Comment.count"], +1 do - conn.insert_fixtures_set(fixtures) + 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 @@ -212,20 +301,6 @@ class FixturesTest < ActiveRecord::TestCase 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: : " @@ -382,11 +457,11 @@ class FixturesTest < ActiveRecord::TestCase end def test_empty_yaml_fixture - assert_not_nil ActiveRecord::FixtureSet.new(Account.connection, "accounts", Account, FIXTURES_ROOT + "/naked/yml/accounts") + assert_not_nil ActiveRecord::FixtureSet.new(nil, "accounts", Account, FIXTURES_ROOT + "/naked/yml/accounts") end def test_empty_yaml_fixture_with_a_comment_in_it - assert_not_nil ActiveRecord::FixtureSet.new(Account.connection, "companies", Company, FIXTURES_ROOT + "/naked/yml/companies") + assert_not_nil ActiveRecord::FixtureSet.new(nil, "companies", Company, FIXTURES_ROOT + "/naked/yml/companies") end def test_nonexistent_fixture_file @@ -396,14 +471,14 @@ class FixturesTest < ActiveRecord::TestCase assert_empty Dir[nonexistent_fixture_path + "*"] assert_raise(Errno::ENOENT) do - ActiveRecord::FixtureSet.new(Account.connection, "companies", Company, nonexistent_fixture_path) + ActiveRecord::FixtureSet.new(nil, "companies", Company, nonexistent_fixture_path) end end def test_dirty_dirty_yaml_file fixture_path = FIXTURES_ROOT + "/naked/yml/courses" error = assert_raise(ActiveRecord::Fixture::FormatError) do - ActiveRecord::FixtureSet.new(Account.connection, "courses", Course, fixture_path) + ActiveRecord::FixtureSet.new(nil, "courses", Course, fixture_path) end assert_equal "fixture is not a hash: #{fixture_path}.yml", error.to_s end @@ -411,7 +486,7 @@ class FixturesTest < ActiveRecord::TestCase def test_yaml_file_with_one_invalid_fixture fixture_path = FIXTURES_ROOT + "/naked/yml/courses_with_invalid_key" error = assert_raise(ActiveRecord::Fixture::FormatError) do - ActiveRecord::FixtureSet.new(Account.connection, "courses", Course, fixture_path) + ActiveRecord::FixtureSet.new(nil, "courses", Course, fixture_path) end assert_equal "fixture key is not a hash: #{fixture_path}.yml, keys: [\"two\"]", error.to_s end @@ -421,11 +496,7 @@ class FixturesTest < ActiveRecord::TestCase ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "parrots") end - if current_adapter?(:SQLite3Adapter) - assert_equal(%(table "parrots" has no column named "arrr".), e.message) - else - assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message) - end + assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message) end def test_yaml_file_with_symbol_columns @@ -434,7 +505,7 @@ class FixturesTest < ActiveRecord::TestCase def test_omap_fixtures assert_nothing_raised do - fixtures = ActiveRecord::FixtureSet.new(Account.connection, "categories", Category, FIXTURES_ROOT + "/categories_ordered") + fixtures = ActiveRecord::FixtureSet.new(nil, "categories", Category, FIXTURES_ROOT + "/categories_ordered") fixtures.each.with_index do |(name, fixture), i| assert_equal "fixture_no_#{i}", name @@ -505,7 +576,7 @@ class HasManyThroughFixture < ActiveRecord::TestCase parrots = File.join FIXTURES_ROOT, "parrots" - fs = ActiveRecord::FixtureSet.new parrot.connection, "parrots", parrot, parrots + fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots) rows = fs.table_rows assert_equal load_has_and_belongs_to_many["parrots_treasures"], rows["parrots_treasures"] end @@ -523,18 +594,22 @@ class HasManyThroughFixture < ActiveRecord::TestCase parrots = File.join FIXTURES_ROOT, "parrots" - fs = ActiveRecord::FixtureSet.new parrot.connection, "parrots", parrot, parrots + fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots) rows = fs.table_rows assert_equal load_has_and_belongs_to_many["parrots_treasures"], rows["parrot_treasures"] end + def test_has_and_belongs_to_many_order + assert_equal ["parrots", "parrots_treasures"], load_has_and_belongs_to_many.keys + end + def load_has_and_belongs_to_many parrot = make_model "Parrot" parrot.has_and_belongs_to_many :treasures parrots = File.join FIXTURES_ROOT, "parrots" - fs = ActiveRecord::FixtureSet.new parrot.connection, "parrots", parrot, parrots + fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots) fs.table_rows end end @@ -592,14 +667,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 @@ -634,7 +709,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 @@ -833,44 +908,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, _lazy: 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 @@ -1082,13 +1171,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 @@ -1239,3 +1328,53 @@ class SameNameDifferentDatabaseFixturesTest < ActiveRecord::TestCase assert_kind_of OtherDog, other_dogs(:lassie) end end + +class NilFixturePathTest < ActiveRecord::TestCase + test "raises an error when all fixtures loaded" do + error = assert_raises(StandardError) do + TestCase = Class.new(ActiveRecord::TestCase) + TestCase.class_eval do + self.fixture_path = nil + fixtures :all + end + end + assert_equal <<~MSG.squish, error.message + No fixture path found. + Please set `NilFixturePathTest::TestCase.fixture_path`. + MSG + end +end + +class MultipleDatabaseFixturesTest < ActiveRecord::TestCase + test "enlist_fixture_connections ensures multiple databases share a connection pool" do + with_temporary_connection_pool do + ActiveRecord::Base.connects_to database: { writing: :arunit, reading: :arunit2 } + + rw_conn = ActiveRecord::Base.connection + ro_conn = ActiveRecord::Base.connection_handlers[:reading].connection_pool_list.first.connection + + assert_not_equal rw_conn, ro_conn + + enlist_fixture_connections + + rw_conn = ActiveRecord::Base.connection + ro_conn = ActiveRecord::Base.connection_handlers[:reading].connection_pool_list.first.connection + + assert_equal rw_conn, ro_conn + end + ensure + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.connection_handler } + end + + private + + def with_temporary_connection_pool + old_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(ActiveRecord::Base.connection_specification_name) + new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new ActiveRecord::Base.connection_pool.spec + ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = new_pool + + yield + ensure + ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = old_pool + end +end diff --git a/activerecord/test/cases/forbidden_attributes_protection_test.rb b/activerecord/test/cases/forbidden_attributes_protection_test.rb index 101fa118c8..e7e31b6d2d 100644 --- a/activerecord/test/cases/forbidden_attributes_protection_test.rb +++ b/activerecord/test/cases/forbidden_attributes_protection_test.rb @@ -1,48 +1,12 @@ # frozen_string_literal: true require "cases/helper" -require "active_support/core_ext/hash/indifferent_access" - require "models/company" require "models/person" require "models/ship" require "models/ship_part" require "models/treasure" - -class ProtectedParams - attr_accessor :permitted - alias :permitted? :permitted - - delegate :keys, :key?, :has_key?, :empty?, to: :@parameters - - def initialize(attributes) - @parameters = attributes.with_indifferent_access - @permitted = false - end - - def permit! - @permitted = true - self - end - - def [](key) - @parameters[key] - end - - def to_h - @parameters - end - - def stringify_keys - dup - end - - def dup - super.tap do |duplicate| - duplicate.instance_variable_set :@permitted, @permitted - end - end -end +require "support/stubs/strong_parameters" class ForbiddenAttributesProtectionTest < ActiveRecord::TestCase def test_forbidden_attributes_cannot_be_used_for_mass_assignment diff --git a/activerecord/test/cases/habtm_destroy_order_test.rb b/activerecord/test/cases/habtm_destroy_order_test.rb index b15e1b48c4..9dbd339fe7 100644 --- a/activerecord/test/cases/habtm_destroy_order_test.rb +++ b/activerecord/test/cases/habtm_destroy_order_test.rb @@ -30,23 +30,21 @@ class HabtmDestroyOrderTest < ActiveRecord::TestCase test "not destroying a student with lessons leaves student<=>lesson association intact" do # test a normal before_destroy doesn't destroy the habtm joins - begin - sicp = Lesson.new(name: "SICP") - ben = Student.new(name: "Ben Bitdiddle") - # add a before destroy to student - Student.class_eval do - before_destroy do - raise ActiveRecord::Rollback unless lessons.empty? - end + sicp = Lesson.new(name: "SICP") + ben = Student.new(name: "Ben Bitdiddle") + # add a before destroy to student + Student.class_eval do + before_destroy do + raise ActiveRecord::Rollback unless lessons.empty? end - ben.lessons << sicp - ben.save! - ben.destroy - assert_not_empty ben.reload.lessons - ensure - # get rid of it so Student is still like it was - Student.reset_callbacks(:destroy) end + ben.lessons << sicp + ben.save! + ben.destroy + assert_not_empty ben.reload.lessons + ensure + # get rid of it so Student is still like it was + Student.reset_callbacks(:destroy) end test "not destroying a lesson with students leaves student<=>lesson association intact" do diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb index 66f11fe5bd..543a0aeb39 100644 --- a/activerecord/test/cases/helper.rb +++ b/activerecord/test/cases/helper.rb @@ -48,8 +48,27 @@ def mysql_enforcing_gtid_consistency? current_adapter?(:Mysql2Adapter) && "ON" == ActiveRecord::Base.connection.show_variable("enforce_gtid_consistency") end -def supports_savepoints? - ActiveRecord::Base.connection.supports_savepoints? +def supports_default_expression? + if current_adapter?(:PostgreSQLAdapter) + true + elsif current_adapter?(:Mysql2Adapter) + conn = ActiveRecord::Base.connection + !conn.mariadb? && conn.database_version >= "8.0.13" + end +end + +%w[ + supports_savepoints? + supports_partial_index? + supports_insert_returning? + supports_insert_on_duplicate_skip? + supports_insert_on_duplicate_update? + supports_insert_conflict_target? + supports_optimizer_hints? +].each do |method_name| + define_method method_name do + ActiveRecord::Base.connection.public_send(method_name) + end end def with_env_tz(new_tz = "US/Eastern") @@ -184,4 +203,4 @@ module InTimeZone end end -require "mocha/minitest" # FIXME: stop using mocha +require_relative "../../../tools/test_common" diff --git a/activerecord/test/cases/hot_compatibility_test.rb b/activerecord/test/cases/hot_compatibility_test.rb index e7778af55b..7b388ebc5e 100644 --- a/activerecord/test/cases/hot_compatibility_test.rb +++ b/activerecord/test/cases/hot_compatibility_test.rb @@ -56,7 +56,7 @@ class HotCompatibilityTest < ActiveRecord::TestCase assert_equal "bar", record.foo end - if current_adapter?(:PostgreSQLAdapter) + if current_adapter?(:PostgreSQLAdapter) && ActiveRecord::Base.connection.prepared_statements test "cleans up after prepared statement failure in a transaction" do with_two_connections do |original_connection, ddl_connection| record = @klass.create! bar: "bar" diff --git a/activerecord/test/cases/inheritance_test.rb b/activerecord/test/cases/inheritance_test.rb index 7a5c06b894..629167e9ed 100644 --- a/activerecord/test/cases/inheritance_test.rb +++ b/activerecord/test/cases/inheritance_test.rb @@ -91,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 @@ -163,7 +162,7 @@ class InheritanceTest < ActiveRecord::TestCase 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 @@ -241,7 +240,7 @@ class InheritanceTest < ActiveRecord::TestCase cabbage = vegetable.becomes!(Cabbage) assert_equal "Cabbage", cabbage.custom_type - vegetable = cabbage.becomes!(Vegetable) + cabbage.becomes!(Vegetable) assert_nil cabbage.custom_type end @@ -515,10 +514,12 @@ class InheritanceComputeTypeTest < ActiveRecord::TestCase # Should fail without FirmOnTheFly in the type condition. assert_raise(ActiveRecord::RecordNotFound) { Firm.find(foo.id) } + assert_raise(ActiveRecord::RecordNotFound) { Firm.find_by!(id: foo.id) } # Nest FirmOnTheFly in the test case where Dependencies won't see it. self.class.const_set :FirmOnTheFly, Class.new(Firm) assert_raise(ActiveRecord::SubclassNotFound) { Firm.find(foo.id) } + assert_raise(ActiveRecord::SubclassNotFound) { Firm.find_by!(id: foo.id) } # Nest FirmOnTheFly in Firm where Dependencies will see it. # This is analogous to nesting models in a migration. @@ -527,6 +528,7 @@ class InheritanceComputeTypeTest < ActiveRecord::TestCase # And instantiate will find the existing constant rather than trying # to require firm_on_the_fly. assert_nothing_raised { assert_kind_of Firm::FirmOnTheFly, Firm.find(foo.id) } + assert_nothing_raised { assert_kind_of Firm::FirmOnTheFly, Firm.find_by!(id: foo.id) } end end @@ -655,7 +657,7 @@ class InheritanceAttributeMappingTest < ActiveRecord::TestCase assert_equal ["omg_inheritance_attribute_mapping_test/company"], ActiveRecord::Base.connection.select_values("SELECT sponsorable_type FROM sponsors") - sponsor = Sponsor.first + sponsor = Sponsor.find(sponsor.id) assert_equal startup, sponsor.sponsorable end end diff --git a/activerecord/test/cases/insert_all_test.rb b/activerecord/test/cases/insert_all_test.rb new file mode 100644 index 0000000000..f24c63031c --- /dev/null +++ b/activerecord/test/cases/insert_all_test.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" + +class ReadonlyNameBook < Book + attr_readonly :name +end + +class InsertAllTest < ActiveRecord::TestCase + fixtures :books + + def test_insert + skip unless supports_insert_on_duplicate_skip? + + id = 1_000_000 + + assert_difference "Book.count", +1 do + Book.insert(id: id, name: "Rework", author_id: 1) + end + + Book.upsert(id: id, name: "Remote", author_id: 1) + + assert_equal "Remote", Book.find(id).name + end + + def test_insert! + assert_difference "Book.count", +1 do + Book.insert! name: "Rework", author_id: 1 + end + end + + def test_insert_all + assert_difference "Book.count", +10 do + Book.insert_all! [ + { name: "Rework", author_id: 1 }, + { name: "Patterns of Enterprise Application Architecture", author_id: 1 }, + { name: "Design of Everyday Things", author_id: 1 }, + { name: "Practical Object-Oriented Design in Ruby", author_id: 1 }, + { name: "Clean Code", author_id: 1 }, + { name: "Ruby Under a Microscope", author_id: 1 }, + { name: "The Principles of Product Development Flow", author_id: 1 }, + { name: "Peopleware", author_id: 1 }, + { name: "About Face", author_id: 1 }, + { name: "Eloquent Ruby", author_id: 1 }, + ] + end + end + + def test_insert_all_should_handle_empty_arrays + assert_raise ArgumentError do + Book.insert_all! [] + end + end + + def test_insert_all_raises_on_duplicate_records + assert_raise ActiveRecord::RecordNotUnique do + Book.insert_all! [ + { name: "Rework", author_id: 1 }, + { name: "Patterns of Enterprise Application Architecture", author_id: 1 }, + { name: "Agile Web Development with Rails", author_id: 1 }, + ] + end + end + + def test_insert_all_returns_ActiveRecord_Result + result = Book.insert_all! [{ name: "Rework", author_id: 1 }] + assert_kind_of ActiveRecord::Result, result + end + + def test_insert_all_returns_primary_key_if_returning_is_supported + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }] + assert_equal %w[ id ], result.columns + end + + def test_insert_all_returns_nothing_if_returning_is_empty + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }], returning: [] + assert_equal [], result.columns + end + + def test_insert_all_returns_nothing_if_returning_is_false + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }], returning: false + assert_equal [], result.columns + end + + def test_insert_all_returns_requested_fields + skip unless supports_insert_returning? + + result = Book.insert_all! [{ name: "Rework", author_id: 1 }], returning: [:id, :name] + assert_equal %w[ Rework ], result.pluck("name") + end + + def test_insert_all_can_skip_duplicate_records + skip unless supports_insert_on_duplicate_skip? + + assert_no_difference "Book.count" do + Book.insert_all [{ id: 1, name: "Agile Web Development with Rails" }] + end + end + + def test_insert_all_with_skip_duplicates_and_autonumber_id_not_given + skip unless supports_insert_on_duplicate_skip? + + assert_difference "Book.count", 1 do + # These two books are duplicates according to an index on %i[author_id name] + # but their IDs are not specified so they will be assigned different IDs + # by autonumber. We will get an exception from MySQL if we attempt to skip + # one of these records by assigning its ID. + Book.insert_all [ + { author_id: 8, name: "Refactoring" }, + { author_id: 8, name: "Refactoring" } + ] + end + end + + def test_insert_all_with_skip_duplicates_and_autonumber_id_given + skip unless supports_insert_on_duplicate_skip? + + assert_difference "Book.count", 1 do + Book.insert_all [ + { id: 200, author_id: 8, name: "Refactoring" }, + { id: 201, author_id: 8, name: "Refactoring" } + ] + end + end + + def test_skip_duplicates_strategy_does_not_secretly_upsert + skip unless supports_insert_on_duplicate_skip? + + book = Book.create!(author_id: 8, name: "Refactoring", format: "EXPECTED") + + assert_no_difference "Book.count" do + Book.insert(author_id: 8, name: "Refactoring", format: "UNEXPECTED") + end + + assert_equal "EXPECTED", book.reload.format + end + + def test_insert_all_will_raise_if_duplicates_are_skipped_only_for_a_certain_conflict_target + skip unless supports_insert_on_duplicate_skip? && supports_insert_conflict_target? + + assert_raise ActiveRecord::RecordNotUnique do + Book.insert_all [{ id: 1, name: "Agile Web Development with Rails" }], + unique_by: :index_books_on_author_id_and_name + end + end + + def test_insert_all_and_upsert_all_with_index_finding_options + skip unless supports_insert_conflict_target? + + assert_difference "Book.count", +3 do + Book.insert_all [{ name: "Rework", author_id: 1 }], unique_by: :isbn + Book.insert_all [{ name: "Remote", author_id: 1 }], unique_by: %i( author_id name ) + Book.insert_all [{ name: "Renote", author_id: 1 }], unique_by: :index_books_on_isbn + end + + assert_raise ActiveRecord::RecordNotUnique do + Book.upsert_all [{ name: "Rework", author_id: 1 }], unique_by: :isbn + end + end + + def test_insert_all_and_upsert_all_raises_when_index_is_missing + skip unless supports_insert_conflict_target? + + [ :cats, %i( author_id isbn ), :author_id ].each do |missing_or_non_unique_by| + error = assert_raises ArgumentError do + Book.insert_all [{ name: "Rework", author_id: 1 }], unique_by: missing_or_non_unique_by + end + assert_match "No unique index", error.message + + error = assert_raises ArgumentError do + Book.upsert_all [{ name: "Rework", author_id: 1 }], unique_by: missing_or_non_unique_by + end + assert_match "No unique index", error.message + end + end + + def test_insert_logs_message_including_model_name + skip unless supports_insert_conflict_target? + + capture_log_output do |output| + Book.insert(name: "Rework", author_id: 1) + assert_match "Book Insert", output.string + end + end + + def test_insert_all_logs_message_including_model_name + skip unless supports_insert_conflict_target? + + capture_log_output do |output| + Book.insert_all [{ name: "Remote", author_id: 1 }, { name: "Renote", author_id: 1 }] + assert_match "Book Bulk Insert", output.string + end + end + + def test_upsert_logs_message_including_model_name + skip unless supports_insert_on_duplicate_update? + + capture_log_output do |output| + Book.upsert(name: "Remote", author_id: 1) + assert_match "Book Upsert", output.string + end + end + + def test_upsert_all_logs_message_including_model_name + skip unless supports_insert_on_duplicate_update? + + capture_log_output do |output| + Book.upsert_all [{ name: "Remote", author_id: 1 }, { name: "Renote", author_id: 1 }] + assert_match "Book Bulk Upsert", output.string + end + end + + def test_upsert_all_updates_existing_records + skip unless supports_insert_on_duplicate_update? + + new_name = "Agile Web Development with Rails, 4th Edition" + Book.upsert_all [{ id: 1, name: new_name }] + assert_equal new_name, Book.find(1).name + end + + def test_upsert_all_does_not_update_readonly_attributes + skip unless supports_insert_on_duplicate_update? + + new_name = "Agile Web Development with Rails, 4th Edition" + ReadonlyNameBook.upsert_all [{ id: 1, name: new_name }] + assert_not_equal new_name, Book.find(1).name + end + + def test_upsert_all_does_not_update_primary_keys + skip unless supports_insert_on_duplicate_update? && supports_insert_conflict_target? + + Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7 }] + Book.upsert_all [{ id: 103, name: "Perelandra", author_id: 7, isbn: "1974522598" }], + unique_by: :index_books_on_author_id_and_name + + book = Book.find_by(name: "Perelandra") + assert_equal 101, book.id, "Should not have updated the ID" + assert_equal "1974522598", book.isbn, "Should have updated the isbn" + end + + def test_upsert_all_does_not_perform_an_upsert_if_a_partial_index_doesnt_apply + skip unless supports_insert_on_duplicate_update? && supports_insert_conflict_target? && supports_partial_index? + + Book.upsert_all [{ name: "Out of the Silent Planet", author_id: 7, isbn: "1974522598", published_on: Date.new(1938, 4, 1) }] + Book.upsert_all [{ name: "Perelandra", author_id: 7, isbn: "1974522598" }], + unique_by: :index_books_on_isbn + + assert_equal ["Out of the Silent Planet", "Perelandra"], Book.where(isbn: "1974522598").order(:name).pluck(:name) + end + + def test_insert_all_raises_on_unknown_attribute + assert_raise ActiveRecord::UnknownAttributeError do + Book.insert_all! [{ unknown_attribute: "Test" }] + end + end + + private + + def capture_log_output + output = StringIO.new + old_logger, ActiveRecord::Base.logger = ActiveRecord::Base.logger, ActiveSupport::Logger.new(output) + + begin + yield output + ensure + ActiveRecord::Base.logger = old_logger + end + end +end diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb index e6e8468757..06382b6c7c 100644 --- a/activerecord/test/cases/instrumentation_test.rb +++ b/activerecord/test/cases/instrumentation_test.rb @@ -5,6 +5,10 @@ require "models/book" module ActiveRecord class InstrumentationTest < ActiveRecord::TestCase + def setup + ActiveRecord::Base.connection.schema_cache.add(Book.table_name) + end + def test_payload_name_on_load Book.create(name: "test book") subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| @@ -37,8 +41,8 @@ module ActiveRecord assert_equal "Book Update", event.payload[:name] end end - book = Book.create(name: "test book") - book.update_attribute(:name, "new name") + book = Book.create(name: "test book", format: "paperback") + book.update_attribute(:format, "ebook") ensure ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber end @@ -50,8 +54,8 @@ module ActiveRecord assert_equal "Book Update All", event.payload[:name] end end - Book.create(name: "test book") - Book.update_all(name: "new name") + Book.create(name: "test book", format: "paperback") + Book.update_all(format: "ebook") ensure ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber end diff --git a/activerecord/test/cases/integration_test.rb b/activerecord/test/cases/integration_test.rb index 36cd63c4d4..4185e8d682 100644 --- a/activerecord/test/cases/integration_test.rb +++ b/activerecord/test/cases/integration_test.rb @@ -157,78 +157,87 @@ class IntegrationTest < ActiveRecord::TestCase skip("Subsecond precision is not supported") unless subsecond_precision_supported? dev = Developer.first key = dev.cache_key - dev.touch + travel_to dev.updated_at + 0.000001 do + dev.touch + end assert_not_equal key, dev.cache_key end def test_cache_key_format_is_not_too_precise - skip("Subsecond precision is not supported") unless subsecond_precision_supported? dev = Developer.first dev.touch key = dev.cache_key assert_equal key, dev.reload.cache_key end - def test_named_timestamps_for_cache_key - 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) + def test_cache_version_format_is_precise_enough + skip("Subsecond precision is not supported") unless subsecond_precision_supported? + with_cache_versioning do + dev = Developer.first + version = dev.cache_version.to_param + travel_to Developer.first.updated_at + 0.000001 do + dev.touch + end + assert_not_equal version, dev.cache_version.to_param end end - def test_cache_key_when_named_timestamp_is_nil - assert_deprecated do - owner = owners(:blackbeard) - owner.happy_at = nil - assert_equal "owners/#{owner.id}", owner.cache_key(:happy_at) + def test_cache_version_format_is_not_too_precise + with_cache_versioning do + dev = Developer.first + dev.touch + key = dev.cache_version.to_param + assert_equal key, dev.reload.cache_version.to_param end end def test_cache_key_is_stable_with_versioning_on - Developer.cache_versioning = true - - developer = Developer.first - first_key = developer.cache_key + with_cache_versioning do + developer = Developer.first + first_key = developer.cache_key - developer.touch - second_key = developer.cache_key + developer.touch + second_key = developer.cache_key - assert_equal first_key, second_key - ensure - Developer.cache_versioning = false + assert_equal first_key, second_key + end end def test_cache_version_changes_with_versioning_on - Developer.cache_versioning = true + with_cache_versioning do + developer = Developer.first + first_version = developer.cache_version - developer = Developer.first - first_version = developer.cache_version + travel 10.seconds do + developer.touch + end - travel 10.seconds do - developer.touch - end - - second_version = developer.cache_version + second_version = developer.cache_version - assert_not_equal first_version, second_version - ensure - Developer.cache_versioning = false + assert_not_equal first_version, second_version + end end def test_cache_key_retains_version_when_custom_timestamp_is_used - Developer.cache_versioning = true + with_cache_versioning do + developer = Developer.first + first_key = developer.cache_key_with_version - developer = Developer.first - first_key = developer.cache_key_with_version + travel 10.seconds do + developer.touch + end - travel 10.seconds do - developer.touch - end + second_key = developer.cache_key_with_version - second_key = developer.cache_key_with_version + assert_not_equal first_key, second_key + end + end - assert_not_equal first_key, second_key + def with_cache_versioning(value = true) + @old_cache_versioning = ActiveRecord::Base.cache_versioning + ActiveRecord::Base.cache_versioning = value + yield ensure - Developer.cache_versioning = false + ActiveRecord::Base.cache_versioning = @old_cache_versioning end end diff --git a/activerecord/test/cases/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb index ebe0b0aa87..d68cc40107 100644 --- a/activerecord/test/cases/invertible_migration_test.rb +++ b/activerecord/test/cases/invertible_migration_test.rb @@ -22,6 +22,14 @@ module ActiveRecord end end + class InvertibleTransactionMigration < InvertibleMigration + def change + transaction do + super + end + end + end + class InvertibleRevertMigration < SilentMigration def change revert do @@ -215,7 +223,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 @@ -223,11 +231,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 @@ -241,12 +249,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 @@ -255,11 +263,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 @@ -268,7 +276,15 @@ 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_transaction + migration = InvertibleTransactionMigration.new + migration.migrate :up + assert migration.connection.table_exists?("horses") + migration.migrate :down + assert_not migration.connection.table_exists?("horses") end def test_migrate_revert_change_column_default @@ -292,6 +308,8 @@ module ActiveRecord migration2 = DisableExtension1.new migration3 = DisableExtension2.new + assert_equal true, Horse.connection.extension_available?("hstore") + migration1.migrate(:up) migration2.migrate(:up) assert_equal true, Horse.connection.extension_enabled?("hstore") @@ -341,7 +359,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 @@ -352,7 +370,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 @@ -361,7 +379,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 @@ -383,7 +401,7 @@ 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 @@ -402,7 +420,7 @@ module ActiveRecord UpOnlyMigration.new.migrate(:down) # should be no error connection = ActiveRecord::Base.connection - assert !connection.column_exists?(:horses, :oldie) + assert_not connection.column_exists?(:horses, :oldie) Horse.reset_column_information end end diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb index 8513edb0ab..33bd74e114 100644 --- a/activerecord/test/cases/locking_test.rb +++ b/activerecord/test/cases/locking_test.rb @@ -445,32 +445,38 @@ class OptimisticLockingTest < ActiveRecord::TestCase assert_equal 0, car.wheels_count assert_equal 0, car.lock_version - previously_car_updated_at = car.updated_at - travel(2.second) do + 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_not_equal previously_car_updated_at, car.updated_at 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_car_updated_at = car.updated_at - travel(1.day) do + 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_not_equal previously_car_updated_at, car.updated_at 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_car_updated_at = car.updated_at - travel(2.second) do + 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_not_equal previously_car_updated_at, car.updated_at 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 diff --git a/activerecord/test/cases/log_subscriber_test.rb b/activerecord/test/cases/log_subscriber_test.rb index e2742ed33e..ae2597adc8 100644 --- a/activerecord/test/cases/log_subscriber_test.rb +++ b/activerecord/test/cases/log_subscriber_test.rb @@ -44,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 @@ -177,11 +178,25 @@ class LogSubscriberTest < ActiveRecord::TestCase 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!")) diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb index 1494027182..cc0587fa50 100644 --- a/activerecord/test/cases/migration/change_schema_test.rb +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -196,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 @@ -205,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 @@ -408,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 @@ -451,7 +462,11 @@ module ActiveRecord end def test_create_table_with_force_cascade_drops_dependent_objects - skip "MySQL > 5.5 does not drop dependent objects with DROP TABLE CASCADE" if current_adapter?(:Mysql2Adapter) + if current_adapter?(:Mysql2Adapter) + skip "MySQL > 5.5 does not drop dependent objects with DROP TABLE CASCADE" + elsif current_adapter?(:SQLite3Adapter) + skip "SQLite3 does not support DROP TABLE CASCADE syntax" + end # can't re-create table referenced by foreign key assert_raises(ActiveRecord::StatementInvalid) do @connection.create_table :trains, force: true diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb index 034bf32165..c108d372d1 100644 --- a/activerecord/test/cases/migration/change_table_test.rb +++ b/activerecord/test/cases/migration/change_table_test.rb @@ -164,6 +164,14 @@ module ActiveRecord end end + def test_column_creates_column_with_index + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}] + @connection.expect :add_index, nil, [:delete_me, :bar, {}] + t.column :bar, :integer, index: true + end + end + def test_index_creates_index with_change_table do |t| @connection.expect :add_index, nil, [:delete_me, :bar, {}] diff --git a/activerecord/test/cases/migration/column_attributes_test.rb b/activerecord/test/cases/migration/column_attributes_test.rb index 3022121f4c..b6064500ee 100644 --- a/activerecord/test/cases/migration/column_attributes_test.rb +++ b/activerecord/test/cases/migration/column_attributes_test.rb @@ -176,11 +176,9 @@ module ActiveRecord if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) def test_out_of_range_limit_should_raise - assert_raise(ActiveRecordError) { add_column :test_models, :integer_too_big, :integer, limit: 10 } - - unless current_adapter?(:PostgreSQLAdapter) - assert_raise(ActiveRecordError) { add_column :test_models, :text_too_big, :text, limit: 0xfffffffff } - end + assert_raise(ArgumentError) { add_column :test_models, :integer_too_big, :integer, limit: 10 } + assert_raise(ArgumentError) { add_column :test_models, :text_too_big, :text, limit: 0xfffffffff } + assert_raise(ArgumentError) { add_column :test_models, :binary_too_big, :binary, limit: 0xfffffffff } end end end diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb index cedd9c44e3..cce3461e18 100644 --- a/activerecord/test/cases/migration/columns_test.rb +++ b/activerecord/test/cases/migration/columns_test.rb @@ -109,18 +109,10 @@ module ActiveRecord add_index "test_models", ["hat_style", "hat_size"], unique: true rename_column "test_models", "hat_size", "size" - if current_adapter? :OracleAdapter - assert_equal ["i_test_models_hat_style_size"], connection.indexes("test_models").map(&:name) - else - assert_equal ["index_test_models_on_hat_style_and_size"], connection.indexes("test_models").map(&:name) - end + assert_equal ["index_test_models_on_hat_style_and_size"], connection.indexes("test_models").map(&:name) rename_column "test_models", "hat_style", "style" - if current_adapter? :OracleAdapter - assert_equal ["i_test_models_style_size"], connection.indexes("test_models").map(&:name) - else - assert_equal ["index_test_models_on_style_and_size"], connection.indexes("test_models").map(&:name) - end + assert_equal ["index_test_models_on_style_and_size"], connection.indexes("test_models").map(&:name) end def test_rename_column_does_not_rename_custom_named_index @@ -144,7 +136,7 @@ module ActiveRecord def test_remove_column_with_multi_column_index # MariaDB starting with 10.2.8 # Dropping a column that is part of a multi-column UNIQUE constraint is not permitted. - skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.version >= "10.2.8" + skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.database_version >= "10.2.8" add_column "test_models", :hat_size, :integer add_column "test_models", :hat_style, :string, limit: 100 @@ -318,6 +310,17 @@ module ActiveRecord ensure connection.drop_table(:my_table) rescue nil end + + def test_add_column_without_column_name + e = assert_raise ArgumentError do + connection.create_table "my_table", force: true do |t| + t.timestamp + end + end + assert_equal "Missing column name(s) for timestamp", e.message + ensure + connection.drop_table :my_table, if_exists: true + end end end end diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb index 3a11bb081b..01f8628fc5 100644 --- a/activerecord/test/cases/migration/command_recorder_test.rb +++ b/activerecord/test/cases/migration/command_recorder_test.rb @@ -117,13 +117,13 @@ module ActiveRecord end def test_invert_create_table_with_options_and_block - block = Proc.new {} + block = Proc.new { } drop_table = @recorder.inverse_of :create_table, [:people_reminders, id: false], &block assert_equal [:drop_table, [:people_reminders, id: false], block], drop_table end def test_invert_drop_table - block = Proc.new {} + block = Proc.new { } create_table = @recorder.inverse_of :drop_table, [:people_reminders, id: false], &block assert_equal [:create_table, [:people_reminders, id: false], block], create_table end @@ -145,7 +145,7 @@ module ActiveRecord end def test_invert_drop_join_table - block = Proc.new {} + block = Proc.new { } create_join_table = @recorder.inverse_of :drop_join_table, [:musics, :artists, table_name: :catalog], &block assert_equal [:create_join_table, [:musics, :artists, table_name: :catalog], block], create_join_table end @@ -329,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"] @@ -347,6 +360,16 @@ module ActiveRecord @recorder.inverse_of :remove_foreign_key, [:dogs] end end + + def test_invert_transaction_with_irreversible_inside_is_irreversible + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.revert do + @recorder.transaction do + @recorder.execute "some sql" + end + end + end + end end end end diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb index 69a50674af..5753bd7117 100644 --- a/activerecord/test/cases/migration/compatibility_test.rb +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -86,8 +86,8 @@ module ActiveRecord ActiveRecord::Migrator.new(:up, [migration]).migrate - assert connection.columns(:more_testings).find { |c| c.name == "created_at" }.null - assert connection.columns(:more_testings).find { |c| c.name == "updated_at" }.null + assert connection.column_exists?(:more_testings, :created_at, null: true) + assert connection.column_exists?(:more_testings, :updated_at, null: true) ensure connection.drop_table :more_testings rescue nil end @@ -103,8 +103,25 @@ module ActiveRecord 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 + assert connection.column_exists?(:testings, :created_at, null: true) + assert connection.column_exists?(:testings, :updated_at, null: true) + end + + if ActiveRecord::Base.connection.supports_bulk_alter? + def test_timestamps_have_null_constraints_if_not_present_in_migration_of_change_table_with_bulk + migration = Class.new(ActiveRecord::Migration[4.2]) { + def migrate(x) + change_table :testings, bulk: true do |t| + t.timestamps + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.column_exists?(:testings, :created_at, null: true) + assert connection.column_exists?(:testings, :updated_at, null: true) + end end def test_timestamps_have_null_constraints_if_not_present_in_migration_for_adding_timestamps_to_existing_table @@ -116,8 +133,70 @@ module ActiveRecord 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 + assert connection.column_exists?(:testings, :created_at, null: true) + assert connection.column_exists?(:testings, :updated_at, null: true) + end + + def test_timestamps_doesnt_set_precision_on_create_table + migration = Class.new(ActiveRecord::Migration[5.2]) { + def migrate(x) + create_table :more_testings do |t| + t.timestamps + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.column_exists?(:more_testings, :created_at, null: false, **precision_implicit_default) + assert connection.column_exists?(:more_testings, :updated_at, null: false, **precision_implicit_default) + ensure + connection.drop_table :more_testings rescue nil + end + + def test_timestamps_doesnt_set_precision_on_change_table + migration = Class.new(ActiveRecord::Migration[5.2]) { + def migrate(x) + change_table :testings do |t| + t.timestamps default: Time.now + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.column_exists?(:testings, :created_at, null: false, **precision_implicit_default) + assert connection.column_exists?(:testings, :updated_at, null: false, **precision_implicit_default) + end + + if ActiveRecord::Base.connection.supports_bulk_alter? + def test_timestamps_doesnt_set_precision_on_change_table_with_bulk + migration = Class.new(ActiveRecord::Migration[5.2]) { + def migrate(x) + change_table :testings, bulk: true do |t| + t.timestamps + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.column_exists?(:testings, :created_at, null: false, **precision_implicit_default) + assert connection.column_exists?(:testings, :updated_at, null: false, **precision_implicit_default) + end + end + + def test_timestamps_doesnt_set_precision_on_add_timestamps + migration = Class.new(ActiveRecord::Migration[5.2]) { + def migrate(x) + add_timestamps :testings, default: Time.now + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.column_exists?(:testings, :created_at, null: false, **precision_implicit_default) + assert connection.column_exists?(:testings, :updated_at, null: false, **precision_implicit_default) end def test_legacy_migrations_raises_exception_when_inherited @@ -127,6 +206,20 @@ module ActiveRecord assert_match(/LegacyMigration < ActiveRecord::Migration\[4\.2\]/, e.message) end + def test_legacy_migrations_not_raise_exception_on_reverting_transaction + migration = Class.new(ActiveRecord::Migration[5.2]) { + def change + transaction do + execute "select 1" + end + end + }.new + + assert_nothing_raised do + migration.migrate(:down) + end + end + if current_adapter?(:PostgreSQLAdapter) class Testing < ActiveRecord::Base end @@ -145,6 +238,15 @@ module ActiveRecord ActiveRecord::Base.clear_cache! end end + + private + def precision_implicit_default + if current_adapter?(:Mysql2Adapter) + { presicion: 0 } + else + { presicion: nil } + end + end 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 83fb4f9385..e0cbb29dcf 100644 --- a/activerecord/test/cases/migration/create_join_table_test.rb +++ b/activerecord/test/cases/migration/create_join_table_test.rb @@ -95,42 +95,42 @@ module ActiveRecord 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 @@ -139,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 50f5696ad1..5f1057f093 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -3,7 +3,7 @@ require "cases/helper" require "support/schema_dumping_helper" -if ActiveRecord::Base.connection.supports_foreign_keys_in_create? +if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ForeignKeyInCreateTest < ActiveRecord::TestCase @@ -19,11 +19,152 @@ 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 + + class CreateRocketsMigration < ActiveRecord::Migration::Current + def up + create_table :rockets do |t| + t.string :name + end + + create_table :astronauts do |t| + t.string :name + t.references :rocket, foreign_key: true + end + end + + def down + drop_table :astronauts, if_exists: true + drop_table :rockets, if_exists: true + end + end + + def setup + @connection = ActiveRecord::Base.connection + @migration = CreateRocketsMigration.new + silence_stream($stdout) { @migration.migrate(:up) } + Rocket.reset_table_name + Rocket.reset_column_information + Astronaut.reset_table_name + Astronaut.reset_column_information + end + + def teardown + silence_stream($stdout) { @migration.migrate(:down) } + Rocket.reset_table_name + Rocket.reset_column_information + Astronaut.reset_table_name + Astronaut.reset_column_information + end + + def test_change_column_of_parent_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.change_column_null Rocket.table_name, :name, false + + foreign_keys = @connection.foreign_keys(Astronaut.table_name) + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal Astronaut.table_name, fk.from_table + assert_equal Rocket.table_name, fk.to_table + end + + def test_rename_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.rename_column Astronaut.table_name, :name, :astronaut_name + + foreign_keys = @connection.foreign_keys(Astronaut.table_name) + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal Astronaut.table_name, fk.from_table + assert_equal Rocket.table_name, fk.to_table + end + + def test_rename_reference_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.rename_column Astronaut.table_name, :rocket_id, :new_rocket_id + + foreign_keys = @connection.foreign_keys(Astronaut.table_name) + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal Astronaut.table_name, fk.from_table + assert_equal Rocket.table_name, fk.to_table + assert_equal "new_rocket_id", fk.options[:column] + end + + def test_remove_reference_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.remove_column Astronaut.table_name, :rocket_id + + assert_empty @connection.foreign_keys(Astronaut.table_name) + end + + def test_remove_foreign_key_by_column + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.remove_foreign_key Astronaut.table_name, column: :rocket_id + + assert_empty @connection.foreign_keys(Astronaut.table_name) + end + + def test_remove_foreign_key_by_column_in_change_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.change_table Astronaut.table_name do |t| + t.remove_foreign_key column: :rocket_id + end + + assert_empty @connection.foreign_keys(Astronaut.table_name) + end + end + + class ForeignKeyChangeColumnWithPrefixTest < ForeignKeyChangeColumnTest + setup do + ActiveRecord::Base.table_name_prefix = "p_" + end + + teardown do + ActiveRecord::Base.table_name_prefix = nil + end + end + + class ForeignKeyChangeColumnWithSuffixTest < ForeignKeyChangeColumnTest + setup do + ActiveRecord::Base.table_name_suffix = "_s" + end + + teardown do + ActiveRecord::Base.table_name_suffix = nil + end + end end end -end -if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ForeignKeyTest < ActiveRecord::TestCase @@ -62,7 +203,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "fk_test_has_pk", fk.to_table assert_equal "fk_id", fk.column assert_equal "pk_id", fk.primary_key - assert_equal "fk_name", fk.name + assert_equal "fk_name", fk.name unless current_adapter?(:SQLite3Adapter) end def test_add_foreign_key_inferes_column @@ -76,7 +217,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "rockets", fk.to_table assert_equal "rocket_id", fk.column assert_equal "id", fk.primary_key - assert_equal("fk_rails_78146ddd2e", fk.name) + assert_equal "fk_rails_78146ddd2e", fk.name unless current_adapter?(:SQLite3Adapter) end def test_add_foreign_key_with_column @@ -90,7 +231,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "rockets", fk.to_table assert_equal "rocket_id", fk.column assert_equal "id", fk.primary_key - assert_equal("fk_rails_78146ddd2e", fk.name) + assert_equal "fk_rails_78146ddd2e", fk.name unless current_adapter?(:SQLite3Adapter) end def test_add_foreign_key_with_non_standard_primary_key @@ -109,7 +250,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? assert_equal "space_shuttles", fk.to_table assert_equal "pk", fk.primary_key ensure - @connection.remove_foreign_key :astronauts, name: "custom_pk" + @connection.remove_foreign_key :astronauts, name: "custom_pk", to_table: "space_shuttles" @connection.drop_table :space_shuttles end @@ -183,6 +324,8 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_foreign_key_exists_by_name + skip if current_adapter?(:SQLite3Adapter) + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk" assert @connection.foreign_key_exists?(:astronauts, name: "fancy_named_fk") @@ -214,6 +357,8 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_remove_foreign_key_by_name + skip if current_adapter?(:SQLite3Adapter) + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk" assert_equal 1, @connection.foreign_keys("astronauts").size @@ -222,9 +367,22 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_remove_foreign_non_existing_foreign_key_raises - assert_raises ArgumentError do + e = assert_raises ArgumentError do @connection.remove_foreign_key :astronauts, :rockets end + assert_equal "Table 'astronauts' has no foreign key for rockets", e.message + end + + def test_remove_foreign_key_by_the_select_one_on_the_same_table + @connection.add_foreign_key :astronauts, :rockets + @connection.add_reference :astronauts, :myrocket, foreign_key: { to_table: :rockets } + + assert_equal 2, @connection.foreign_keys("astronauts").size + + @connection.remove_foreign_key :astronauts, :rockets, column: "myrocket_id" + + assert_equal [["astronauts", "rockets", "rocket_id"]], + @connection.foreign_keys("astronauts").map { |fk| [fk.from_table, fk.to_table, fk.column] } end if ActiveRecord::Base.connection.supports_validate_constraints? @@ -303,7 +461,11 @@ if ActiveRecord::Base.connection.supports_foreign_keys? def test_schema_dumping_with_options output = dump_table_schema "fk_test_has_fk" - assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output + if current_adapter?(:SQLite3Adapter) + assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id"$}, output + else + assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output + end end def test_schema_dumping_with_custom_fk_ignore_pattern @@ -357,7 +519,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end class CreateSchoolsAndClassesMigration < ActiveRecord::Migration::Current - def change + def up create_table(:schools) create_table(:classes) do |t| @@ -365,6 +527,11 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end add_foreign_key :classes, :schools end + + def down + drop_table :classes, if_exists: true + drop_table :schools, if_exists: true + end end def test_add_foreign_key_with_prefix @@ -389,30 +556,4 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end end -else - module ActiveRecord - class Migration - class NoForeignKeySupportTest < ActiveRecord::TestCase - setup do - @connection = ActiveRecord::Base.connection - end - - def test_add_foreign_key_should_be_noop - @connection.add_foreign_key :clubs, :categories - end - - def test_remove_foreign_key_should_be_noop - @connection.remove_foreign_key :clubs, :categories - end - - unless current_adapter?(:SQLite3Adapter) - def test_foreign_keys_should_raise_not_implemented - assert_raises NotImplementedError do - @connection.foreign_keys("clubs") - end - end - end - end - end - end end diff --git a/activerecord/test/cases/migration/index_test.rb b/activerecord/test/cases/migration/index_test.rb index b25c6d84bc..5e688efc2b 100644 --- a/activerecord/test/cases/migration/index_test.rb +++ b/activerecord/test/cases/migration/index_test.rb @@ -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 @@ -155,14 +158,11 @@ module ActiveRecord connection.add_index("testings", ["last_name", "first_name"]) connection.remove_index("testings", column: ["last_name", "first_name"]) - # Oracle adapter cannot have specified index name larger than 30 characters - # Oracle adapter is shortening index name when just column list is given - unless current_adapter?(:OracleAdapter) - connection.add_index("testings", ["last_name", "first_name"]) - connection.remove_index("testings", name: :index_testings_on_last_name_and_first_name) - connection.add_index("testings", ["last_name", "first_name"]) - connection.remove_index("testings", "last_name_and_first_name") - end + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", name: :index_testings_on_last_name_and_first_name) + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", "last_name_and_first_name") + connection.add_index("testings", ["last_name", "first_name"]) connection.remove_index("testings", ["last_name", "first_name"]) @@ -203,7 +203,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/pending_migrations_test.rb b/activerecord/test/cases/migration/pending_migrations_test.rb index dedb5ea502..119bfd372a 100644 --- a/activerecord/test/cases/migration/pending_migrations_test.rb +++ b/activerecord/test/cases/migration/pending_migrations_test.rb @@ -25,7 +25,7 @@ module ActiveRecord ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true assert_raises ActiveRecord::PendingMigrationError do - CheckPending.new(Proc.new {}).call({}) + CheckPending.new(Proc.new { }).call({}) end end @@ -34,7 +34,7 @@ module ActiveRecord migrator = Base.connection.migration_context capture(:stdout) { migrator.migrate } - assert_nil CheckPending.new(Proc.new {}).call({}) + 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 7a092103c7..90a50a5651 100644 --- a/activerecord/test/cases/migration/references_foreign_key_test.rb +++ b/activerecord/test/cases/migration/references_foreign_key_test.rb @@ -2,7 +2,7 @@ require "cases/helper" -if ActiveRecord::Base.connection.supports_foreign_keys_in_create? +if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ReferencesForeignKeyInCreateTest < ActiveRecord::TestCase @@ -65,9 +65,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys_in_create? end end end -end -if ActiveRecord::Base.connection.supports_foreign_keys? module ActiveRecord class Migration class ReferencesForeignKeyTest < ActiveRecord::TestCase @@ -152,35 +150,38 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end test "foreign key methods respect pluralize_table_names" do - begin - original_pluralize_table_names = ActiveRecord::Base.pluralize_table_names - ActiveRecord::Base.pluralize_table_names = false - @connection.create_table :testing - @connection.change_table :testing_parents do |t| - t.references :testing, foreign_key: true - end + original_pluralize_table_names = ActiveRecord::Base.pluralize_table_names + ActiveRecord::Base.pluralize_table_names = false + @connection.create_table :testing + @connection.change_table :testing_parents do |t| + t.references :testing, foreign_key: true + end - fk = @connection.foreign_keys("testing_parents").first - assert_equal "testing_parents", fk.from_table - assert_equal "testing", fk.to_table + fk = @connection.foreign_keys("testing_parents").first + assert_equal "testing_parents", fk.from_table + assert_equal "testing", fk.to_table - assert_difference "@connection.foreign_keys('testing_parents').size", -1 do - @connection.remove_reference :testing_parents, :testing, foreign_key: true - end - ensure - ActiveRecord::Base.pluralize_table_names = original_pluralize_table_names - @connection.drop_table "testing", if_exists: true + assert_difference "@connection.foreign_keys('testing_parents').size", -1 do + @connection.remove_reference :testing_parents, :testing, foreign_key: true end + ensure + ActiveRecord::Base.pluralize_table_names = original_pluralize_table_names + @connection.drop_table "testing", if_exists: true end class CreateDogsMigration < ActiveRecord::Migration::Current - def change + def up create_table :dog_owners create_table :dogs do |t| t.references :dog_owner, foreign_key: true end end + + def down + drop_table :dogs, if_exists: true + drop_table :dog_owners, if_exists: true + end end def test_references_foreign_key_with_prefix @@ -234,24 +235,4 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end end -else - class ReferencesWithoutForeignKeySupportTest < ActiveRecord::TestCase - setup do - @connection = ActiveRecord::Base.connection - @connection.create_table(:testing_parents, force: true) - end - - teardown do - @connection.drop_table("testings", if_exists: true) - @connection.drop_table("testing_parents", if_exists: true) - end - - test "ignores foreign keys defined with the table" do - @connection.create_table :testings do |t| - t.references :testing_parent, foreign_key: true - end - - assert_includes @connection.data_sources, "testings" - end - end end diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index e3fd7d1a7b..8e8ed494d9 100644 --- a/activerecord/test/cases/migration_test.rb +++ b/activerecord/test/cases/migration_test.rb @@ -71,13 +71,10 @@ 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 + def test_passing_migrations_paths_to_assume_migrated_upto_version_is_deprecated + ActiveRecord::SchemaMigration.create_table assert_deprecated do - ActiveRecord::Migrator.migrations_path = "db/migrate" + ActiveRecord::Base.connection.assume_migrated_upto_version(0, []) end end @@ -87,7 +84,6 @@ class MigrationTest < ActiveRecord::TestCase def test_migrator_versions migrations_path = MIGRATIONS_ROOT + "/valid" - old_path = ActiveRecord::Migrator.migrations_paths migrator = ActiveRecord::MigrationContext.new(migrations_path) migrator.up @@ -100,24 +96,18 @@ class MigrationTest < ActiveRecord::TestCase ActiveRecord::SchemaMigration.create!(version: 3) assert_equal true, migrator.needs_migration? - ensure - ActiveRecord::MigrationContext.new(old_path) end def test_migration_detection_without_schema_migration_table ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true migrations_path = MIGRATIONS_ROOT + "/valid" - old_path = ActiveRecord::Migrator.migrations_paths migrator = ActiveRecord::MigrationContext.new(migrations_path) assert_equal true, migrator.needs_migration? - ensure - ActiveRecord::MigrationContext.new(old_path) end def test_any_migrations - old_path = ActiveRecord::Migrator.migrations_paths migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid") assert_predicate migrator, :any_migrations? @@ -125,8 +115,6 @@ class MigrationTest < ActiveRecord::TestCase migrator_empty = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/empty") assert_not_predicate migrator_empty, :any_migrations? - ensure - ActiveRecord::MigrationContext.new(old_path) end def test_migration_version @@ -136,6 +124,36 @@ class MigrationTest < ActiveRecord::TestCase assert_equal 20131219224947, migrator.current_version end + def test_create_table_raises_if_already_exists + connection = Person.connection + connection.create_table :testings, force: true do |t| + t.string :foo + end + + assert_raise(ActiveRecord::StatementInvalid) do + connection.create_table :testings do |t| + t.string :foo + end + end + ensure + connection.drop_table :testings, if_exists: true + end + + def test_create_table_with_if_not_exists_true + connection = Person.connection + connection.create_table :testings, force: true do |t| + t.string :foo + end + + assert_nothing_raised do + connection.create_table :testings, if_not_exists: true do |t| + t.string :foo + end + end + ensure + connection.drop_table :testings, if_exists: true + end + def test_create_table_with_force_true_does_not_drop_nonexisting_table # using a copy as we need the drop_table method to # continue to work for the ensure block of the test @@ -262,21 +280,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 @@ -367,6 +385,7 @@ class MigrationTest < ActiveRecord::TestCase assert_equal "changed", ActiveRecord::SchemaMigration.table_name ensure ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name + ActiveRecord::SchemaMigration.reset_table_name Reminder.reset_table_name end @@ -387,13 +406,13 @@ class MigrationTest < ActiveRecord::TestCase assert_equal "changed", ActiveRecord::InternalMetadata.table_name ensure ActiveRecord::Base.internal_metadata_table_name = original_internal_metadata_table_name + ActiveRecord::InternalMetadata.reset_table_name Reminder.reset_table_name end def test_internal_metadata_stores_environment current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call migrations_path = MIGRATIONS_ROOT + "/valid" - old_path = ActiveRecord::Migrator.migrations_paths migrator = ActiveRecord::MigrationContext.new(migrations_path) migrator.up @@ -410,7 +429,6 @@ class MigrationTest < ActiveRecord::TestCase migrator.up assert_equal new_env, ActiveRecord::InternalMetadata[:environment] ensure - migrator = ActiveRecord::MigrationContext.new(old_path) ENV["RAILS_ENV"] = original_rails_env ENV["RACK_ENV"] = original_rack_env migrator.up @@ -422,16 +440,11 @@ class MigrationTest < ActiveRecord::TestCase current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call migrations_path = MIGRATIONS_ROOT + "/valid" - old_path = ActiveRecord::Migrator.migrations_paths - current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call migrator = ActiveRecord::MigrationContext.new(migrations_path) migrator.up assert_equal current_env, ActiveRecord::InternalMetadata[:environment] assert_equal "bar", ActiveRecord::InternalMetadata[:foo] - ensure - migrator = ActiveRecord::MigrationContext.new(old_path) - migrator.up end def test_proper_table_name_on_migration @@ -548,7 +561,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 @@ -556,69 +569,68 @@ class MigrationTest < ActiveRecord::TestCase end end - if current_adapter? :OracleAdapter - def test_create_table_with_custom_sequence_name - # table name is 29 chars, the standard sequence name will - # be 33 chars and should be shortened - assert_nothing_raised do - begin - Person.connection.create_table :table_with_name_thats_just_ok do |t| - t.column :foo, :string, null: false - end - ensure - Person.connection.drop_table :table_with_name_thats_just_ok rescue nil - end - end - - # should be all good w/ a custom sequence name - assert_nothing_raised do - begin - Person.connection.create_table :table_with_name_thats_just_ok, - sequence_name: "suitably_short_seq" do |t| - t.column :foo, :string, null: false - end - - Person.connection.execute("select suitably_short_seq.nextval from dual") - - ensure - Person.connection.drop_table :table_with_name_thats_just_ok, - sequence_name: "suitably_short_seq" rescue nil - end - end - - # confirm the custom sequence got dropped - assert_raise(ActiveRecord::StatementInvalid) do - Person.connection.execute("select suitably_short_seq.nextval from dual") + def test_decimal_scale_without_precision_should_raise + e = assert_raise(ArgumentError) do + Person.connection.create_table :test_decimal_scales, force: true do |t| + t.decimal :scaleonly, scale: 10 end end + + assert_equal "Error adding decimal column: precision cannot be empty if scale is specified", e.message + ensure + Person.connection.drop_table :test_decimal_scales, if_exists: true end if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) def test_out_of_range_integer_limit_should_raise - e = assert_raise(ActiveRecord::ActiveRecordError, "integer limit didn't raise") do + e = assert_raise(ArgumentError) do Person.connection.create_table :test_integer_limits, force: true do |t| t.column :bigone, :integer, limit: 10 end end - assert_match(/No integer type has byte size 10/, e.message) + assert_includes e.message, "No integer type has byte size 10" ensure Person.connection.drop_table :test_integer_limits, if_exists: true end - end - if current_adapter?(:Mysql2Adapter) def test_out_of_range_text_limit_should_raise - e = assert_raise(ActiveRecord::ActiveRecordError, "text limit didn't raise") do + e = assert_raise(ArgumentError) do Person.connection.create_table :test_text_limits, force: true do |t| t.text :bigtext, limit: 0xfffffffff end end - assert_match(/No text type has byte length #{0xfffffffff}/, e.message) + assert_includes e.message, "No text type has byte size #{0xfffffffff}" ensure Person.connection.drop_table :test_text_limits, if_exists: true end + + def test_out_of_range_binary_limit_should_raise + e = assert_raise(ArgumentError) do + Person.connection.create_table :test_binary_limits, force: true do |t| + t.binary :bigbinary, limit: 0xfffffffff + end + end + + assert_includes e.message, "No binary type has byte size #{0xfffffffff}" + ensure + Person.connection.drop_table :test_binary_limits, if_exists: true + end + end + + if current_adapter?(:Mysql2Adapter) + def test_invalid_text_size_should_raise + e = assert_raise(ArgumentError) do + Person.connection.create_table :test_text_sizes, force: true do |t| + t.text :bigtext, size: 0xfffffffff + end + end + + assert_equal "#{0xfffffffff} is invalid :size value. Only :tiny, :medium, and :long are allowed.", e.message + ensure + Person.connection.drop_table :test_text_sizes, if_exists: true + end end if ActiveRecord::Base.connection.supports_advisory_locks? @@ -727,15 +739,13 @@ class MigrationTest < ActiveRecord::TestCase test_terminated = Concurrent::CountDownLatch.new other_process = Thread.new do - begin - conn = ActiveRecord::Base.connection_pool.checkout - conn.get_advisory_lock(lock_id) - thread_lock.count_down - test_terminated.wait # hold the lock open until we tested everything - ensure - conn.release_advisory_lock(lock_id) - ActiveRecord::Base.connection_pool.checkin(conn) - end + conn = ActiveRecord::Base.connection_pool.checkout + conn.get_advisory_lock(lock_id) + thread_lock.count_down + test_terminated.wait # hold the lock open until we tested everything + ensure + conn.release_advisory_lock(lock_id) + ActiveRecord::Base.connection_pool.checkin(conn) end thread_lock.wait # wait until the 'other process' has the lock @@ -793,12 +803,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 @@ -806,6 +824,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 @@ -822,7 +841,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 @@ -852,7 +871,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 @@ -880,7 +899,7 @@ if ActiveRecord::Base.connection.supports_bulk_alter? 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 @@ -892,7 +911,7 @@ 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 classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] @@ -1150,7 +1169,7 @@ class CopyMigrationsTest < ActiveRecord::TestCase def test_check_pending_with_stdlib_logger old, ActiveRecord::Base.logger = ActiveRecord::Base.logger, ::Logger.new($stdout) quietly do - assert_nothing_raised { ActiveRecord::Migration::CheckPending.new(Proc.new {}).call({}) } + assert_nothing_raised { ActiveRecord::Migration::CheckPending.new(Proc.new { }).call({}) } end ensure ActiveRecord::Base.logger = old diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb index 873455cf67..30e199f1c5 100644 --- a/activerecord/test/cases/migrator_test.rb +++ b/activerecord/test/cases/migrator_test.rb @@ -100,7 +100,6 @@ class MigratorTest < ActiveRecord::TestCase def test_finds_migrations_in_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 assert_equal migrations[i].name, pair.last diff --git a/activerecord/test/cases/modules_test.rb b/activerecord/test/cases/modules_test.rb index 060d555607..87455e4fcb 100644 --- a/activerecord/test/cases/modules_test.rb +++ b/activerecord/test/cases/modules_test.rb @@ -32,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 @@ -155,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 @@ -166,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/multiple_db_test.rb b/activerecord/test/cases/multiple_db_test.rb index 192d2f5251..f11c441c65 100644 --- a/activerecord/test/cases/multiple_db_test.rb +++ b/activerecord/test/cases/multiple_db_test.rb @@ -106,14 +106,12 @@ class MultipleDbTest < ActiveRecord::TestCase end def test_associations_should_work_when_model_has_no_connection - begin - ActiveRecord::Base.remove_connection - assert_nothing_raised do - College.first.courses.first - end - ensure - ActiveRecord::Base.establish_connection :arunit + ActiveRecord::Base.remove_connection + assert_nothing_raised do + College.first.courses.first end + ensure + ActiveRecord::Base.establish_connection :arunit end end end diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb index 1ed3a61bbb..bb1c1ea17d 100644 --- a/activerecord/test/cases/nested_attributes_test.rb +++ b/activerecord/test/cases/nested_attributes_test.rb @@ -83,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 @@ -217,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 @@ -669,7 +681,6 @@ module NestedAttributesOnACollectionAssociationTests 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" }, @@ -835,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 @@ -1095,3 +1106,15 @@ class TestHasManyAutosaveAssociationWhichItselfHasAutosaveAssociations < ActiveR 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/null_relation_test.rb b/activerecord/test/cases/null_relation_test.rb index 17527568f8..ee96ea1af6 100644 --- a/activerecord/test/cases/null_relation_test.rb +++ b/activerecord/test/cases/null_relation_test.rb @@ -10,26 +10,27 @@ class NullRelationTest < ActiveRecord::TestCase fixtures :posts, :comments def test_none - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal [], Developer.none assert_equal [], Developer.all.none end end def test_none_chainable - assert_no_queries(ignore_none: false) do + Developer.send(:load_schema) + assert_no_queries 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_no_queries 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_no_queries do assert_equal [], Developer.none.pluck(:id, :name) assert_equal 0, Developer.none.delete_all assert_equal 0, Developer.none.update_all(name: "David") @@ -39,7 +40,7 @@ class NullRelationTest < ActiveRecord::TestCase end def test_null_relation_content_size_methods - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal 0, Developer.none.size assert_equal 0, Developer.none.count assert_equal true, Developer.none.empty? @@ -61,7 +62,7 @@ class NullRelationTest < ActiveRecord::TestCase [:count, :sum].each do |method| define_method "test_null_relation_#{method}" do - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_equal 0, Comment.none.public_send(method, :id) assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id) end @@ -70,7 +71,7 @@ class NullRelationTest < ActiveRecord::TestCase [:average, :minimum, :maximum].each do |method| define_method "test_null_relation_#{method}" do - assert_no_queries(ignore_none: false) do + assert_no_queries do assert_nil Comment.none.public_send(method, :id) assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id) end diff --git a/activerecord/test/cases/numeric_data_test.rb b/activerecord/test/cases/numeric_data_test.rb index 14db63890e..079e664ee4 100644 --- a/activerecord/test/cases/numeric_data_test.rb +++ b/activerecord/test/cases/numeric_data_test.rb @@ -24,8 +24,10 @@ class NumericDataTest < ActiveRecord::TestCase ) assert m.save - m1 = NumericData.find(m.id) - assert_not_nil m1 + m1 = NumericData.find_by( + bank_balance: 1586.43, + big_bank_balance: BigDecimal("1000234000567.95") + ) assert_kind_of Integer, m1.world_population assert_equal 2**62, m1.world_population @@ -49,8 +51,10 @@ class NumericDataTest < ActiveRecord::TestCase ) assert m.save - m1 = NumericData.find(m.id) - assert_not_nil m1 + m1 = NumericData.find_by( + bank_balance: 1586.43122334, + big_bank_balance: BigDecimal("234000567.952344") + ) assert_kind_of Integer, m1.world_population assert_equal 2**62, m1.world_population @@ -64,4 +68,26 @@ class NumericDataTest < ActiveRecord::TestCase assert_kind_of BigDecimal, m1.big_bank_balance assert_equal BigDecimal("234000567.95"), m1.big_bank_balance end + + if current_adapter?(:PostgreSQLAdapter) + def test_numeric_fields_with_nan + m = NumericData.new( + bank_balance: BigDecimal("NaN"), + big_bank_balance: BigDecimal("NaN"), + world_population: 2**62, + my_house_population: 3 + ) + assert_predicate m.bank_balance, :nan? + assert_predicate m.big_bank_balance, :nan? + assert m.save + + m1 = NumericData.find_by( + bank_balance: BigDecimal("NaN"), + big_bank_balance: BigDecimal("NaN") + ) + + assert_predicate m1.bank_balance, :nan? + assert_predicate m1.big_bank_balance, :nan? + end + end end diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb index e4a65a48ca..d5057ad381 100644 --- a/activerecord/test/cases/persistence_test.rb +++ b/activerecord/test/cases/persistence_test.rb @@ -13,90 +13,15 @@ require "models/developer" require "models/computer" require "models/project" require "models/minimalistic" -require "models/warehouse_thing" require "models/parrot" require "models/minivan" -require "models/owner" require "models/person" -require "models/pet" require "models/ship" -require "models/toy" require "models/admin" require "models/admin/user" -require "rexml/document" class PersistenceTest < ActiveRecord::TestCase - fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, "warehouse-things", :authors, :author_addresses, :categorizations, :categories, :posts, :minivans, :pets, :toys - - # Oracle UPDATE does not support ORDER BY - unless current_adapter?(:OracleAdapter) - def test_update_all_ignores_order_without_limit_from_association - author = authors(:david) - assert_nothing_raised do - assert_equal author.posts_with_comments_and_categories.length, author.posts_with_comments_and_categories.update_all([ "body = ?", "bulk update!" ]) - end - end - - def test_update_all_doesnt_ignore_order - assert_equal authors(:david).id + 1, authors(:mary).id # make sure there is going to be a duplicate PK error - test_update_with_order_succeeds = lambda do |order| - begin - Author.order(order).update_all("id = id + 1") - rescue ActiveRecord::ActiveRecordError - false - end - 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 - 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 - test_update_with_order_succeeds.call("id DESC") - end - end - end - - def test_update_all_with_order_and_limit_updates_subset_only - author = authors(:david) - 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 + fixtures :topics, :companies, :developers, :accounts, :minimalistics, :authors, :author_addresses, :posts, :minivans def test_update_many topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } @@ -128,6 +53,20 @@ class PersistenceTest < ActiveRecord::TestCase assert_not_equal "2 updated", Topic.find(2).content end + def test_class_level_update_without_ids + topics = Topic.all + assert_equal 5, topics.length + topics.each do |topic| + assert_not_equal "updated", topic.content + end + + updated = Topic.update(content: "updated") + assert_equal 5, updated.length + updated.each do |topic| + assert_equal "updated", topic.content + end + end + def test_class_level_update_is_affected_by_scoping topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } @@ -145,34 +84,6 @@ class PersistenceTest < ActiveRecord::TestCase assert_equal Topic.count, Topic.delete_all end - def test_delete_all_with_joins_and_where_part_is_hash - 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 true, pets.exists? - assert_equal pets.count, pets.delete_all - end - - def test_delete_all_with_left_joins - pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) - - assert_equal true, pets.exists? - assert_equal pets.count, pets.delete_all - end - - def test_delete_all_with_includes - pets = Pet.includes(:toys).where(toys: { name: "Bone" }) - - assert_equal true, pets.exists? - assert_equal pets.count, pets.delete_all - end - def test_increment_attribute assert_equal 50, accounts(:signals37).credit_limit accounts(:signals37).increment! :credit_limit @@ -206,24 +117,33 @@ 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) - 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 + 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_destroy_all - conditions = "author_name = 'Mary'" - topics_by_mary = Topic.all.merge!(where: conditions, order: "id").to_a - assert_not_empty topics_by_mary - - assert_difference("Topic.count", -topics_by_mary.size) do - destroyed = Topic.where(conditions).destroy_all.sort_by(&:id) - assert_equal topics_by_mary, destroyed - assert destroyed.all?(&:frozen?), "destroyed topics should be frozen" + def test_increment_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.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_increment_with_no_arg + topic = topics(:first) + assert_raises(ArgumentError) { topic.increment! } end def test_destroy_many @@ -290,6 +210,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) @@ -322,12 +253,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) + 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) - 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 + 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 @@ -513,19 +460,17 @@ class PersistenceTest < ActiveRecord::TestCase end def test_update_attribute_does_not_run_sql_if_attribute_is_not_changed - klass = Class.new(Topic) do - def self.name; "Topic"; end - end - topic = klass.create(title: "Another New Topic") - assert_queries(0) do + topic = Topic.create(title: "Another New Topic") + assert_no_queries do assert topic.update_attribute(:title, "Another New Topic") end end 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(title: "Another New Topic") } + assert_no_queries do + assert topic.update(title: "Another New Topic") + end end def test_delete @@ -595,32 +540,6 @@ class PersistenceTest < ActiveRecord::TestCase assert_nil Topic.find(2).last_read end - def test_update_all_with_joins - pets = Pet.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_left_joins - 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 true, pets.exists? - assert_equal pets.count, pets.update_all(name: "Bob") - end - - def test_update_all_with_non_standard_table_name - assert_equal 1, WarehouseThing.where(id: 1).update_all(["value = ?", 0]) - assert_equal 0, WarehouseThing.find(1).value - end - def test_delete_new_record client = Client.new(name: "37signals") client.delete @@ -692,8 +611,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 @@ -1142,21 +1061,19 @@ class PersistenceTest < ActiveRecord::TestCase ActiveRecord::Base.connection.disable_query_cache! end - class SaveTest < ActiveRecord::TestCase - def test_save_touch_false - pet = Pet.create!( - name: "Bob", - created_at: 1.day.ago, - updated_at: 1.day.ago) + def test_save_touch_false + parrot = Parrot.create!( + name: "Bob", + created_at: 1.day.ago, + updated_at: 1.day.ago) - created_at = pet.created_at - updated_at = pet.updated_at + created_at = parrot.created_at + updated_at = parrot.updated_at - pet.name = "Barb" - pet.save!(touch: false) - assert_equal pet.created_at, created_at - assert_equal pet.updated_at, updated_at - end + parrot.name = "Barb" + parrot.save!(touch: false) + assert_equal parrot.created_at, created_at + assert_equal parrot.updated_at, updated_at end def test_reset_column_information_resets_children diff --git a/activerecord/test/cases/pooled_connections_test.rb b/activerecord/test/cases/pooled_connections_test.rb index fa7f759e51..080aeb0989 100644 --- a/activerecord/test/cases/pooled_connections_test.rb +++ b/activerecord/test/cases/pooled_connections_test.rb @@ -25,14 +25,12 @@ class PooledConnectionsTest < ActiveRecord::TestCase @timed_out = 0 threads.times do Thread.new do - begin - conn = ActiveRecord::Base.connection_pool.checkout - sleep 0.1 - ActiveRecord::Base.connection_pool.checkin conn - @connection_count += 1 - rescue ActiveRecord::ConnectionTimeoutError - @timed_out += 1 - end + conn = ActiveRecord::Base.connection_pool.checkout + sleep 0.1 + ActiveRecord::Base.connection_pool.checkin conn + @connection_count += 1 + rescue ActiveRecord::ConnectionTimeoutError + @timed_out += 1 end.join end end @@ -42,14 +40,12 @@ class PooledConnectionsTest < ActiveRecord::TestCase @connection_count = 0 @timed_out = 0 loops.times do - begin - conn = ActiveRecord::Base.connection_pool.checkout - ActiveRecord::Base.connection_pool.checkin conn - @connection_count += 1 - ActiveRecord::Base.connection.data_sources - rescue ActiveRecord::ConnectionTimeoutError - @timed_out += 1 - end + conn = ActiveRecord::Base.connection_pool.checkout + ActiveRecord::Base.connection_pool.checkin conn + @connection_count += 1 + ActiveRecord::Base.connection.data_sources + rescue ActiveRecord::ConnectionTimeoutError + @timed_out += 1 end end diff --git a/activerecord/test/cases/primary_keys_test.rb b/activerecord/test/cases/primary_keys_test.rb index 60dac91ec9..4759d3b6b2 100644 --- a/activerecord/test/cases/primary_keys_test.rb +++ b/activerecord/test/cases/primary_keys_test.rb @@ -305,6 +305,7 @@ class PrimaryKeyAnyTypeTest < ActiveRecord::TestCase 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? @@ -353,7 +354,6 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase end def test_composite_primary_key_out_of_order - skip if current_adapter?(:SQLite3Adapter) assert_equal ["code", "region"], @connection.primary_keys("barcodes_reverse") end @@ -375,7 +375,6 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase end def test_dumping_composite_primary_key_out_of_order - skip if current_adapter?(:SQLite3Adapter) schema = dump_table_schema "barcodes_reverse" assert_match %r{create_table "barcodes_reverse", primary_key: \["code", "region"\]}, schema end diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb index 07be046fb7..eb32b690aa 100644 --- a/activerecord/test/cases/query_cache_test.rb +++ b/activerecord/test/cases/query_cache_test.rb @@ -13,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? @@ -26,6 +27,7 @@ class QueryCacheTest < ActiveRecord::TestCase end def sql(event) + @events << event super rescue @exception = true @@ -53,88 +55,97 @@ class QueryCacheTest < ActiveRecord::TestCase assert_cache :off end - private def with_temporary_connection_pool - old_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(ActiveRecord::Base.connection_specification_name) - new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new ActiveRecord::Base.connection_pool.spec - ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = new_pool + def test_query_cache_is_applied_to_connections_in_all_handlers + ActiveRecord::Base.connection_handlers = { + writing: ActiveRecord::Base.default_connection_handler, + reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new + } + + ActiveRecord::Base.connected_to(role: :reading) do + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"]) + end - yield + mw = middleware { |env| + ro_conn = ActiveRecord::Base.connection_handlers[:reading].connection_pool_list.first.connection + assert_predicate ActiveRecord::Base.connection, :query_cache_enabled + assert_predicate ro_conn, :query_cache_enabled + } + + mw.call({}) ensure - ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = old_pool + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } end def test_query_cache_across_threads with_temporary_connection_pool do - begin - if in_memory_db? - # Separate connections to an in-memory database create an entirely new database, - # with an empty schema etc, so we just stub out this schema on the fly. - ActiveRecord::Base.connection_pool.with_connection do |connection| - connection.create_table :tasks do |t| - t.datetime :starting - t.datetime :ending - end + if in_memory_db? + # Separate connections to an in-memory database create an entirely new database, + # with an empty schema etc, so we just stub out this schema on the fly. + ActiveRecord::Base.connection_pool.with_connection do |connection| + 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 + ActiveRecord::FixtureSet.create_fixtures(self.class.fixture_path, ["tasks"], {}, ActiveRecord::Base) + end - ActiveRecord::Base.connection_pool.connections.each do |conn| - assert_cache :off, conn - end + ActiveRecord::Base.connection_pool.connections.each do |conn| + assert_cache :off, conn + end - assert_not_predicate ActiveRecord::Base.connection, :nil? - assert_cache :off + assert_not_predicate ActiveRecord::Base.connection, :nil? + assert_cache :off - middleware { - assert_cache :clean + middleware { + assert_cache :clean - Task.find 1 - assert_cache :dirty + Task.find 1 + assert_cache :dirty - thread_1_connection = ActiveRecord::Base.connection - ActiveRecord::Base.clear_active_connections! - assert_cache :off, thread_1_connection + thread_1_connection = ActiveRecord::Base.connection + ActiveRecord::Base.clear_active_connections! + assert_cache :off, thread_1_connection - started = Concurrent::Event.new - checked = Concurrent::Event.new + started = Concurrent::Event.new + checked = Concurrent::Event.new - thread_2_connection = nil - thread = Thread.new { - thread_2_connection = ActiveRecord::Base.connection + thread_2_connection = nil + thread = Thread.new { + thread_2_connection = ActiveRecord::Base.connection - assert_equal thread_2_connection, thread_1_connection - assert_cache :off + assert_equal thread_2_connection, thread_1_connection + assert_cache :off - middleware { - assert_cache :clean + middleware { + assert_cache :clean - Task.find 1 - assert_cache :dirty + Task.find 1 + assert_cache :dirty - started.set - checked.wait + started.set + checked.wait - ActiveRecord::Base.clear_active_connections! - }.call({}) - } + ActiveRecord::Base.clear_active_connections! + }.call({}) + } - started.wait + started.wait - thread_1_connection = ActiveRecord::Base.connection - assert_not_equal thread_1_connection, thread_2_connection - assert_cache :dirty, thread_2_connection - checked.set - thread.join + thread_1_connection = ActiveRecord::Base.connection + assert_not_equal thread_1_connection, thread_2_connection + assert_cache :dirty, thread_2_connection + checked.set + thread.join - assert_cache :off, thread_2_connection - }.call({}) + assert_cache :off, thread_2_connection + }.call({}) - ActiveRecord::Base.connection_pool.connections.each do |conn| - assert_cache :off, conn - end - ensure - ActiveRecord::Base.connection_pool.disconnect! + ActiveRecord::Base.connection_pool.connections.each do |conn| + assert_cache :off, conn end + ensure + ActiveRecord::Base.connection_pool.disconnect! end end @@ -198,7 +209,7 @@ class QueryCacheTest < ActiveRecord::TestCase Task.cache do assert_queries(2) { Task.find(1); Task.find(2) } end - assert_queries(0) { Task.find(1); Task.find(1); Task.find(2) } + assert_no_queries { Task.find(1); Task.find(1); Task.find(2) } end end @@ -265,6 +276,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 @@ -283,7 +314,7 @@ class QueryCacheTest < ActiveRecord::TestCase payload[:sql].downcase! end - assert_raises frozen_error_class do + assert_raises FrozenError do ActiveRecord::Base.cache do assert_queries(1) { Task.find(1); Task.find(1) } end @@ -341,12 +372,10 @@ class QueryCacheTest < ActiveRecord::TestCase assert_not_predicate Task, :connected? Task.cache do - begin - 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 - end + 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 end end end @@ -360,7 +389,7 @@ class QueryCacheTest < ActiveRecord::TestCase end # Check that if the same query is run again, no queries are executed - assert_queries(0) do + assert_no_queries do assert_equal 0, Post.where(title: "test").to_a.count end @@ -415,8 +444,9 @@ class QueryCacheTest < ActiveRecord::TestCase # Clear places where type information is cached Task.reset_column_information Task.initialize_find_by_cache + Task.define_attribute_methods - assert_queries(0) do + assert_no_queries do Task.find(1) end end @@ -460,7 +490,6 @@ class QueryCacheTest < ActiveRecord::TestCase assert_not ActiveRecord::Base.connection.query_cache_enabled }.join }.call({}) - end end @@ -473,7 +502,56 @@ class QueryCacheTest < ActiveRecord::TestCase }.call({}) end + def test_clear_query_cache_is_called_on_all_connections + skip "with in memory db, reading role won't be able to see database on writing role" if in_memory_db? + with_temporary_connection_pool do + ActiveRecord::Base.connection_handlers = { + writing: ActiveRecord::Base.default_connection_handler, + reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new + } + + ActiveRecord::Base.connected_to(role: :reading) do + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"]) + end + + mw = middleware { |env| + ActiveRecord::Base.connected_to(role: :reading) do + @topic = Topic.first + end + + assert @topic + + ActiveRecord::Base.connected_to(role: :writing) do + @topic.title = "It doesn't have to be crazy at work" + @topic.save! + end + + assert_equal "It doesn't have to be crazy at work", @topic.title + + ActiveRecord::Base.connected_to(role: :reading) do + @topic = Topic.first + assert_equal "It doesn't have to be crazy at work", @topic.title + end + } + + mw.call({}) + end + ensure + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + private + + def with_temporary_connection_pool + old_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(ActiveRecord::Base.connection_specification_name) + new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new ActiveRecord::Base.connection_pool.spec + ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = new_pool + + yield + ensure + ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = old_pool + end + def middleware(&app) executor = Class.new(ActiveSupport::Executor) ActiveRecord::QueryCache.install_executor_hooks executor @@ -483,14 +561,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 @@ -518,19 +596,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 diff --git a/activerecord/test/cases/quoting_test.rb b/activerecord/test/cases/quoting_test.rb index 92eb0c814f..723fccc8d9 100644 --- a/activerecord/test/cases/quoting_test.rb +++ b/activerecord/test/cases/quoting_test.rb @@ -71,8 +71,8 @@ module ActiveRecord with_timezone_config default: :utc do t = Time.now.change(usec: 0) - expected = t.getutc.change(year: 2000, month: 1, day: 1) - expected = expected.to_s(:db).sub("2000-01-01 ", "") + 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 @@ -89,6 +89,32 @@ module ActiveRecord 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) diff --git a/activerecord/test/cases/readonly_test.rb b/activerecord/test/cases/readonly_test.rb index 383e43ed55..059fa76132 100644 --- a/activerecord/test/cases/readonly_test.rb +++ b/activerecord/test/cases/readonly_test.rb @@ -23,7 +23,7 @@ class ReadOnlyTest < ActiveRecord::TestCase assert_nothing_raised do dev.name = "Luscious forbidden fruit." - assert !dev.save + assert_not dev.save dev.name = "Forbidden." end @@ -38,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 @@ -55,14 +55,14 @@ class ReadOnlyTest < ActiveRecord::TestCase def test_has_many_find_readonly post = Post.find(1) assert_not_empty post.comments - assert !post.comments.any?(&:readonly?) - assert !post.comments.to_a.any?(&:readonly?) + 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 diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 61cb0f130d..402ddcf05a 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -36,19 +36,19 @@ module ActiveRecord # 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 - until fp.reaped + until fp.flushed Thread.pass end assert fp.reaped @@ -61,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 diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index ed19192ad9..abadafbad4 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -75,7 +75,7 @@ class ReflectionTest < ActiveRecord::TestCase 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 diff --git a/activerecord/test/cases/relation/delegation_test.rb b/activerecord/test/cases/relation/delegation_test.rb index 3f3d41980c..085006c9a2 100644 --- a/activerecord/test/cases/relation/delegation_test.rb +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -5,7 +5,7 @@ require "models/post" require "models/comment" module ActiveRecord - module DelegationWhitelistTests + module DelegationTests ARRAY_DELEGATES = [ :+, :-, :|, :&, :[], :shuffle, :all?, :collect, :compact, :detect, :each, :each_cons, :each_with_index, @@ -21,25 +21,14 @@ module ActiveRecord assert_respond_to target, method end end - end - - 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 test_deprecate_arel_delegation - AREL_METHODS.each do |method| - assert_deprecated { target.public_send(method) } - assert_deprecated { target.public_send(method) } - end + def test_not_respond_to_arel_method + assert_not_respond_to target, :exists end end class DelegationAssociationTest < ActiveRecord::TestCase - include DelegationWhitelistTests - include DeprecatedArelDelegationTests + include DelegationTests def target Post.new.comments @@ -47,8 +36,7 @@ module ActiveRecord end class DelegationRelationTest < ActiveRecord::TestCase - include DelegationWhitelistTests - include DeprecatedArelDelegationTests + include DelegationTests def target Comment.all @@ -56,26 +44,28 @@ module ActiveRecord end class QueryingMethodsDelegationTest < ActiveRecord::TestCase - QUERYING_METHODS = [ - :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, - :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, - :first_or_create, :first_or_create!, :first_or_initialize, - :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, - :find_by, :find_by!, - :destroy_all, :delete_all, :update_all, - :find_each, :find_in_batches, :in_batches, - :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, - :having, :create_with, :distinct, :references, :none, :unscope, :merge, - :count, :average, :minimum, :maximum, :sum, :calculate, - :pluck, :pick, :ids, - ] + QUERYING_METHODS = + ActiveRecord::Batches.public_instance_methods(false) + + ActiveRecord::Calculations.public_instance_methods(false) + + ActiveRecord::FinderMethods.public_instance_methods(false) - [:raise_record_not_found_exception!] + + ActiveRecord::SpawnMethods.public_instance_methods(false) - [:spawn, :merge!] + + ActiveRecord::QueryMethods.public_instance_methods(false).reject { |method| + method.to_s.end_with?("=", "!", "value", "values", "clause") + } - [:reverse_order, :arel, :extensions, :construct_join_dependency] + [ + :any?, :many?, :none?, :one?, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, + :create_or_find_by, :create_or_find_by!, + :destroy_all, :delete_all, :update_all, :touch_all, :delete_by, :destroy_by + ] def test_delegate_querying_methods klass = Class.new(ActiveRecord::Base) do self.table_name = "posts" end + assert_equal QUERYING_METHODS.sort, ActiveRecord::Querying::QUERYING_METHODS.sort + QUERYING_METHODS.each do |method| assert_respond_to klass.all, method assert_respond_to klass, method diff --git a/activerecord/test/cases/relation/delete_all_test.rb b/activerecord/test/cases/relation/delete_all_test.rb new file mode 100644 index 0000000000..d1c13fa1b5 --- /dev/null +++ b/activerecord/test/cases/relation/delete_all_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/post" +require "models/pet" +require "models/toy" + +class DeleteAllTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :posts, :pets, :toys + + def test_destroy_all + davids = Author.where(name: "David") + + # Force load + assert_equal [authors(:david)], davids.to_a + assert_predicate davids, :loaded? + + assert_difference("Author.count", -1) do + destroyed = davids.destroy_all + assert_equal [authors(:david)], destroyed + assert_predicate destroyed.first, :frozen? + end + + assert_equal [], davids.to_a + assert_predicate davids, :loaded? + end + + def test_delete_all + davids = Author.where(name: "David") + + assert_difference("Author.count", -1) { davids.delete_all } + assert_not_predicate davids, :loaded? + end + + def test_delete_all_loaded + davids = Author.where(name: "David") + + # Force load + assert_equal [authors(:david)], davids.to_a + assert_predicate davids, :loaded? + + assert_difference("Author.count", -1) { davids.delete_all } + + assert_equal [], davids.to_a + assert_predicate davids, :loaded? + end + + def test_delete_all_with_unpermitted_relation_raises_error + 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 } + end + + def test_delete_all_with_joins_and_where_part_is_hash + 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 true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + def test_delete_all_with_left_joins + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + def test_delete_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + 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 diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb index 074ce9454f..5c5e760e34 100644 --- a/activerecord/test/cases/relation/merging_test.rb +++ b/activerecord/test/cases/relation/merging_test.rb @@ -78,6 +78,10 @@ class RelationMergingTest < ActiveRecord::TestCase 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 @@ -117,6 +121,32 @@ class RelationMergingTest < ActiveRecord::TestCase relation = relation.merge(Post.from("posts")) assert_not_empty relation.from_clause end + + def test_merging_with_from_clause_on_different_class + assert Comment.joins(:post).merge(Post.from("posts")).first + 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 + + def test_merging_annotations_respects_merge_order + assert_sql(%r{/\* foo \*/ /\* bar \*/}) do + Post.annotate("foo").merge(Post.annotate("bar")).first + end + assert_sql(%r{/\* bar \*/ /\* foo \*/}) do + Post.annotate("bar").merge(Post.annotate("foo")).first + end + assert_sql(%r{/\* foo \*/ /\* bar \*/ /\* baz \*/ /\* qux \*/}) do + Post.annotate("foo").annotate("bar").merge(Post.annotate("baz").annotate("qux")).first + end + end end class MergingDifferentRelationsTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb index f82ecd4449..96249b8d51 100644 --- a/activerecord/test/cases/relation/mutation_test.rb +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -26,7 +26,7 @@ module ActiveRecord assert relation.order!(:name).equal?(relation) node = relation.order_values.first assert_predicate node, :ascending? - assert_equal :name, node.expr.name + assert_equal "name", node.expr.name assert_equal "posts", node.expr.relation.name end @@ -89,7 +89,7 @@ module ActiveRecord node = relation.order_values.first assert_predicate node, :ascending? - assert_equal :name, node.expr.name + assert_equal "name", node.expr.name assert_equal "posts", node.expr.relation.name end diff --git a/activerecord/test/cases/relation/or_test.rb b/activerecord/test/cases/relation/or_test.rb index 7e418f9c7d..8623867864 100644 --- a/activerecord/test/cases/relation/or_test.rb +++ b/activerecord/test/cases/relation/or_test.rb @@ -30,6 +30,11 @@ module ActiveRecord assert_equal expected, Post.where("id = 1").or(Post.none).to_a end + def test_or_with_large_number + expected = Post.where("id = 1 or id = 9223372036854775808").to_a + assert_equal expected, Post.where(id: 1).or(Post.where(id: 9223372036854775808)).to_a + end + def test_or_with_bind_params assert_equal Post.find([1, 2]).sort_by(&:id), Post.where(id: 1).or(Post.where(id: 2)).sort_by(&:id) end @@ -126,5 +131,12 @@ module ActiveRecord 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/select_test.rb b/activerecord/test/cases/relation/select_test.rb index 0577e6bfdb..586aaadd0a 100644 --- a/activerecord/test/cases/relation/select_test.rb +++ b/activerecord/test/cases/relation/select_test.rb @@ -7,9 +7,21 @@ module ActiveRecord class SelectTest < ActiveRecord::TestCase fixtures :posts - def test_select_with_nil_agrument + def test_select_with_nil_argument expected = Post.select(:title).to_sql assert_equal expected, Post.select(nil).select(:title).to_sql end + + def test_reselect + expected = Post.select(:title).to_sql + assert_equal expected, Post.select(:title, :body).reselect(:title).to_sql + end + + def test_reselect_with_default_scope_select + expected = Post.select(:title).to_sql + actual = PostWithDefaultSelect.reselect(:title).to_sql + + assert_equal expected, actual + end end end diff --git a/activerecord/test/cases/relation/update_all_test.rb b/activerecord/test/cases/relation/update_all_test.rb new file mode 100644 index 0000000000..e45531b4a9 --- /dev/null +++ b/activerecord/test/cases/relation/update_all_test.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/category" +require "models/comment" +require "models/computer" +require "models/developer" +require "models/post" +require "models/person" +require "models/pet" +require "models/toy" +require "models/topic" +require "models/tag" +require "models/tagging" +require "models/warehouse_thing" + +class UpdateAllTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :comments, :developers, :posts, :people, :pets, :toys, :tags, :taggings, "warehouse-things" + + 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_update_all_with_scope + tag = Tag.first + Post.tagged_with(tag.id).update_all(title: "rofl") + posts = Post.tagged_with(tag.id).all.to_a + assert_operator posts.length, :>, 0 + posts.each { |post| assert_equal "rofl", post.title } + end + + def test_update_all_with_non_standard_table_name + assert_equal 1, WarehouseThing.where(id: 1).update_all(["value = ?", 0]) + assert_equal 0, WarehouseThing.find(1).value + end + + def test_update_all_with_blank_argument + assert_raises(ArgumentError) { Comment.update_all({}) } + end + + def test_update_all_with_joins + pets = Pet.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_left_joins + 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 true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") + end + + def test_update_all_with_joins_and_limit + comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).limit(1) + assert_equal 1, comments.update_all(post_id: posts(:thinking).id) + assert_equal posts(:thinking), comments(:greetings).post + end + + def test_update_all_with_joins_and_limit_and_order + comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).order("comments.id").limit(1) + assert_equal 1, comments.update_all(post_id: posts(:thinking).id) + assert_equal posts(:thinking), comments(:greetings).post + assert_equal posts(:welcome), comments(:more_greetings).post + end + + def test_update_all_with_joins_and_offset + all_comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id) + count = all_comments.count + comments = all_comments.offset(1) + + assert_equal count - 1, comments.update_all(post_id: posts(:thinking).id) + end + + def test_update_all_with_joins_and_offset_and_order + all_comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).order("posts.id", "comments.id") + count = all_comments.count + comments = all_comments.offset(1) + + assert_equal count - 1, comments.update_all(post_id: posts(:thinking).id) + assert_equal posts(:thinking), comments(:more_greetings).post + assert_equal posts(:welcome), comments(:greetings).post + end + + def test_update_counters_with_joins + assert_nil pets(:parrot).integer + + Pet.joins(:toys).where(toys: { name: "Bone" }).update_counters(integer: 1) + + assert_equal 1, pets(:parrot).reload.integer + 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.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.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_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 + assert_equal "David", topic1.reload.author_name + assert_equal "David", topic2.reload.author_name + end + + def test_update_on_relation_passing_active_record_object_is_not_permitted + topic = Topic.create!(title: "Foo", author_name: nil) + assert_raises(ArgumentError) do + Topic.where(id: topic.id).update(topic, title: "Bar") + end + end + + def test_update_all_cares_about_optimistic_locking + david = people(:david) + + travel 5.seconds do + now = Time.now.utc + assert_not_equal now, david.updated_at + + people = Person.where(id: people(:michael, :david, :susan)) + expected = people.pluck(:lock_version) + expected.map! { |version| version + 1 } + people.update_all(updated_at: now) + + assert_equal [now] * 3, people.pluck(:updated_at) + assert_equal expected, people.pluck(:lock_version) + + assert_raises(ActiveRecord::StaleObjectError) do + david.touch(time: now) + end + end + end + + def test_update_counters_cares_about_optimistic_locking + david = people(:david) + + travel 5.seconds do + now = Time.now.utc + assert_not_equal now, david.updated_at + + people = Person.where(id: people(:michael, :david, :susan)) + expected = people.pluck(:lock_version) + expected.map! { |version| version + 1 } + people.update_counters(touch: [time: now]) + + assert_equal [now] * 3, people.pluck(:updated_at) + assert_equal expected, people.pluck(:lock_version) + + assert_raises(ActiveRecord::StaleObjectError) do + david.touch(time: now) + end + end + end + + def test_touch_all_cares_about_optimistic_locking + david = people(:david) + + travel 5.seconds do + now = Time.now.utc + assert_not_equal now, david.updated_at + + people = Person.where(id: people(:michael, :david, :susan)) + expected = people.pluck(:lock_version) + expected.map! { |version| version + 1 } + people.touch_all(time: now) + + assert_equal [now] * 3, people.pluck(:updated_at) + assert_equal expected, people.pluck(:lock_version) + + assert_raises(ActiveRecord::StaleObjectError) do + david.touch(time: now) + end + end + end + + def test_klass_level_update_all + travel 5.seconds do + now = Time.now.utc + + Person.all.each do |person| + assert_not_equal now, person.updated_at + end + + Person.update_all(updated_at: now) + + Person.all.each do |person| + assert_equal now, person.updated_at + end + end + end + + def test_klass_level_touch_all + travel 5.seconds do + now = Time.now.utc + + Person.all.each do |person| + assert_not_equal now, person.updated_at + end + + Person.touch_all(time: now) + + Person.all.each do |person| + assert_equal now, person.updated_at + end + end + end + + # Oracle UPDATE does not support ORDER BY + unless current_adapter?(:OracleAdapter) + def test_update_all_ignores_order_without_limit_from_association + author = authors(:david) + assert_nothing_raised do + assert_equal author.posts_with_comments_and_categories.length, author.posts_with_comments_and_categories.update_all([ "body = ?", "bulk update!" ]) + end + end + + def test_update_all_doesnt_ignore_order + assert_equal authors(:david).id + 1, authors(:mary).id # make sure there is going to be a duplicate PK error + test_update_with_order_succeeds = lambda do |order| + Author.order(order).update_all("id = id + 1") + rescue ActiveRecord::ActiveRecordError + false + end + + if test_update_with_order_succeeds.call("id DESC") + # 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") + 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 + test_update_with_order_succeeds.call("id DESC") + end + end + end + + def test_update_all_with_order_and_limit_updates_subset_only + author = authors(:david) + 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 + end +end diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb index 8703d238a0..0b06cec40b 100644 --- a/activerecord/test/cases/relation/where_clause_test.rb +++ b/activerecord/test/cases/relation/where_clause_test.rb @@ -92,12 +92,16 @@ class ActiveRecord::Relation original = WhereClause.new([ table["id"].in([1, 2, 3]), table["id"].eq(1), + table["id"].is_not_distinct_from(1), + table["id"].is_distinct_from(2), "sql literal", random_object ]) expected = WhereClause.new([ table["id"].not_in([1, 2, 3]), table["id"].not_eq(1), + table["id"].is_distinct_from(1), + table["id"].is_not_distinct_from(2), Arel::Nodes::Not.new(Arel::Nodes::SqlLiteral.new("sql literal")), Arel::Nodes::Not.new(random_object) ]) diff --git a/activerecord/test/cases/relation/where_test.rb b/activerecord/test/cases/relation/where_test.rb index 99797528b2..b045184d7d 100644 --- a/activerecord/test/cases/relation/where_test.rb +++ b/activerecord/test/cases/relation/where_test.rb @@ -14,6 +14,7 @@ require "models/price_estimate" require "models/topic" require "models/treasure" require "models/vertex" +require "support/stubs/strong_parameters" module ActiveRecord class WhereTest < ActiveRecord::TestCase @@ -50,8 +51,13 @@ module ActiveRecord assert_equal [chef], chefs.to_a end - def test_where_with_casted_value_is_nil - assert_equal 4, Topic.where(last_read: "").count + def test_where_with_invalid_value + topics(:first).update!(parent_id: 0, written_on: nil, bonus_time: nil, last_read: nil) + assert_empty Topic.where(parent_id: Object.new) + assert_empty Topic.where(parent_id: "not-a-number") + assert_empty Topic.where(written_on: "") + assert_empty Topic.where(bonus_time: "") + assert_empty Topic.where(last_read: "") end def test_rewhere_on_root @@ -334,31 +340,22 @@ module ActiveRecord end def test_where_with_strong_parameters - protected_params = Class.new do - attr_reader :permitted - alias :permitted? :permitted - - def initialize(parameters) - @parameters = parameters - @permitted = false - end - - def to_h - @parameters - end - - def permit! - @permitted = true - self - end - end - author = authors(:david) - params = protected_params.new(name: author.name) + params = ProtectedParams.new(name: author.name) assert_raises(ActiveModel::ForbiddenAttributesError) { Author.where(params) } assert_equal author, Author.where(params.permit!).first end + def test_where_with_large_number + assert_equal [authors(:bob)], Author.where(id: [3, 9223372036854775808]) + assert_equal [authors(:bob)], Author.where(id: 3..9223372036854775808) + end + + def test_to_sql_with_large_number + assert_equal [authors(:bob)], Author.find_by_sql(Author.where(id: [3, 9223372036854775808]).to_sql) + assert_equal [authors(:bob)], Author.find_by_sql(Author.where(id: 3..9223372036854775808).to_sql) + end + def test_where_with_unsupported_arguments assert_raises(ArgumentError) { Author.where(42) } end diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 0f446e06aa..3f370e5ede 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -5,16 +5,17 @@ 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, :author_addresses, :ratings + fixtures :posts, :comments, :authors, :author_addresses, :ratings, :categorizations def test_construction 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 @@ -100,6 +101,9 @@ module ActiveRecord relation.merge!(relation) assert_predicate relation, :empty_scope? + + assert_not_predicate NullPost.all, :empty_scope? + assert_not_predicate FirstPost.all, :empty_scope? end def test_bad_constants_raise_errors @@ -223,6 +227,30 @@ module ActiveRecord 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+#{Regexp.escape(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 post = Post.create!(title: "haha", body: "huhu") comment = post.comments.create!(body: "hu") @@ -264,6 +292,7 @@ module ActiveRecord klass.create!(description: "foo") assert_equal ["foo"], klass.select(:description).from(klass.all).map(&:desc) + assert_equal ["foo"], klass.reselect(:description).from(klass.all).map(&:desc) end def test_relation_merging_with_merged_joins_as_strings @@ -282,6 +311,58 @@ module ActiveRecord assert_equal 3, ratings.count end + def test_relation_with_annotation_includes_comment_in_to_sql + post_with_annotation = Post.where(id: 1).annotate("foo") + assert_match %r{= 1 /\* foo \*/}, post_with_annotation.to_sql + end + + def test_relation_with_annotation_includes_comment_in_sql + post_with_annotation = Post.where(id: 1).annotate("foo") + assert_sql(%r{/\* foo \*/}) do + assert post_with_annotation.first, "record should be found" + end + end + + def test_relation_with_annotation_chains_sql_comments + post_with_annotation = Post.where(id: 1).annotate("foo").annotate("bar") + assert_sql(%r{/\* foo \*/ /\* bar \*/}) do + assert post_with_annotation.first, "record should be found" + end + end + + def test_relation_with_annotation_filters_sql_comment_delimiters + post_with_annotation = Post.where(id: 1).annotate("**//foo//**") + assert_match %r{= 1 /\* foo \*/}, post_with_annotation.to_sql + end + + def test_relation_with_annotation_includes_comment_in_count_query + post_with_annotation = Post.annotate("foo") + all_count = Post.all.to_a.count + assert_sql(%r{/\* foo \*/}) do + assert_equal all_count, post_with_annotation.count + end + end + + def test_relation_without_annotation_does_not_include_an_empty_comment + log = capture_sql do + Post.where(id: 1).first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + end + + def test_relation_with_optimizer_hints_filters_sql_comment_delimiters + post_with_hint = Post.where(id: 1).optimizer_hints("**//BADHINT//**") + assert_match %r{BADHINT}, post_with_hint.to_sql + assert_no_match %r{\*/BADHINT}, post_with_hint.to_sql + assert_no_match %r{\*//BADHINT}, post_with_hint.to_sql + assert_no_match %r{BADHINT/\*}, post_with_hint.to_sql + assert_no_match %r{BADHINT//\*}, post_with_hint.to_sql + post_with_hint = Post.where(id: 1).optimizer_hints("/*+ BADHINT */") + assert_match %r{/\*\+ BADHINT \*/}, post_with_hint.to_sql + end + class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value def type :string diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index cf65789b97..2417775ef1 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -9,9 +9,12 @@ 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" +require "models/contract" require "models/bird" require "models/car" require "models/engine" @@ -25,12 +28,7 @@ require "models/edge" require "models/subscriber" class RelationTest < ActiveRecord::TestCase - fixtures :authors, :author_addresses, :topics, :entrants, :developers, :companies, :developers_projects, :accounts, :categories, :categorizations, :categories_posts, :posts, :comments, :tags, :taggings, :cars, :minivans - - class TopicWithCallbacks < ActiveRecord::Base - self.table_name = :topics - before_update { |topic| topic.author_name = "David" if topic.author_name.blank? } - end + fixtures :authors, :author_addresses, :topics, :entrants, :developers, :people, :companies, :developers_projects, :accounts, :categories, :categorizations, :categories_posts, :posts, :comments, :tags, :taggings, :cars, :minivans def test_do_not_double_quote_string_id van = Minivan.last @@ -184,15 +182,64 @@ class RelationTest < ActiveRecord::TestCase end end + def test_select_with_from_includes_original_table_name + relation = Comment.joins(:post).select(:id).order(:id) + subquery = Comment.from("#{Comment.table_name} /*! USE INDEX (PRIMARY) */").joins(:post).select(:id).order(:id) + assert_equal relation.map(&:id), subquery.map(&:id) + end + + def test_pluck_with_from_includes_original_table_name + relation = Comment.joins(:post).order(:id) + subquery = Comment.from("#{Comment.table_name} /*! USE INDEX (PRIMARY) */").joins(:post).order(:id) + assert_equal relation.pluck(:id), subquery.pluck(:id) + end + + def test_select_with_from_includes_quoted_original_table_name + relation = Comment.joins(:post).select(:id).order(:id) + subquery = Comment.from("#{Comment.quoted_table_name} /*! USE INDEX (PRIMARY) */").joins(:post).select(:id).order(:id) + assert_equal relation.map(&:id), subquery.map(&:id) + end + + def test_pluck_with_from_includes_quoted_original_table_name + relation = Comment.joins(:post).order(:id) + subquery = Comment.from("#{Comment.quoted_table_name} /*! USE INDEX (PRIMARY) */").joins(:post).order(:id) + assert_equal relation.pluck(:id), subquery.pluck(:id) + end + + def test_select_with_subquery_in_from_uses_original_table_name + relation = Comment.joins(:post).select(:id).order(:id) + # Avoid subquery flattening by adding distinct to work with SQLite < 3.20.0. + subquery = Comment.from(Comment.all.distinct, Comment.quoted_table_name).joins(:post).select(:id).order(:id) + assert_equal relation.map(&:id), subquery.map(&:id) + end + + def test_pluck_with_subquery_in_from_uses_original_table_name + relation = Comment.joins(:post).order(:id) + subquery = Comment.from(Comment.all, Comment.quoted_table_name).joins(:post).order(:id) + assert_equal relation.pluck(:id), subquery.pluck(:id) + end + def test_select_with_subquery_in_from_does_not_use_original_table_name relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") - subquery = Comment.from(relation).select("type", "post_count") + subquery = Comment.from(relation, "grouped_#{Comment.table_name}").select("type", "post_count") assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) end def test_group_with_subquery_in_from_does_not_use_original_table_name relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") - subquery = Comment.from(relation).group("type").average("post_count") + subquery = Comment.from(relation, "grouped_#{Comment.table_name}").group("type").average("post_count") + assert_equal(relation.map(&:post_count).sort, subquery.values.sort) + end + + def test_select_with_subquery_string_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") + subquery = Comment.from("(#{relation.to_sql}) #{Comment.table_name}_grouped").select("type", "post_count") + assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) + end + + def test_group_with_subquery_string_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") + subquery = Comment.from("(#{relation.to_sql}) #{Comment.table_name}_grouped").group("type").average("post_count") assert_equal(relation.map(&:post_count).sort, subquery.values.sort) end @@ -293,8 +340,17 @@ class RelationTest < ActiveRecord::TestCase Topic.order(Arel.sql("title NULLS FIRST")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("title NULLS FIRST")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do Topic.order(Arel.sql("title nulls last")).reverse_order end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("title NULLS FIRST, author_name")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("author_name, title nulls last")).reverse_order + end end def test_default_reverse_order_on_table_without_primary_key @@ -483,21 +539,6 @@ class RelationTest < ActiveRecord::TestCase assert_nothing_raised { Topic.reorder([]) } end - def test_respond_to_delegates_to_arel - relation = Topic.all - fake_arel = Struct.new(:responds) { - def respond_to?(method, access = false) - responds << [method, access] - end - }.new [] - - relation.extend(Module.new { attr_accessor :arel }) - relation.arel = fake_arel - - relation.respond_to?(:matching_attributes) - assert_equal [:matching_attributes, false], fake_arel.responds.first - end - def test_respond_to_dynamic_finders relation = Topic.all @@ -511,7 +552,7 @@ class RelationTest < ActiveRecord::TestCase 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 @@ -561,6 +602,13 @@ class RelationTest < ActiveRecord::TestCase end end + def test_extracted_association + relation_authors = assert_queries(2) { Post.all.extract_associated(:author) } + root_authors = assert_queries(2) { Post.extract_associated(:author) } + assert_equal relation_authors, root_authors + assert_equal Post.all.collect(&:author), relation_authors + end + def test_find_with_included_associations assert_queries(2) do posts = Post.includes(:comments).order("posts.id") @@ -859,45 +907,6 @@ class RelationTest < ActiveRecord::TestCase assert_equal authors(:bob), authors.last end - def test_destroy_all - davids = Author.where(name: "David") - - # Force load - assert_equal [authors(:david)], davids.to_a - assert_predicate davids, :loaded? - - assert_difference("Author.count", -1) { davids.destroy_all } - - assert_equal [], davids.to_a - assert_predicate davids, :loaded? - end - - def test_delete_all - davids = Author.where(name: "David") - - assert_difference("Author.count", -1) { davids.delete_all } - assert_not_predicate davids, :loaded? - end - - def test_delete_all_loaded - davids = Author.where(name: "David") - - # Force load - assert_equal [authors(:david)], davids.to_a - assert_predicate davids, :loaded? - - assert_difference("Author.count", -1) { davids.delete_all } - - assert_equal [], davids.to_a - assert_predicate davids, :loaded? - end - - def test_delete_all_with_unpermitted_relation_raises_error - 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 } - end - def test_select_with_aggregates posts = Post.select(:title, :body) @@ -976,18 +985,23 @@ class RelationTest < ActiveRecord::TestCase assert_queries(1) { assert_equal 11, posts.load.size } end + def test_size_with_eager_loading_and_custom_select_and_order + posts = Post.includes(:comments).order("comments.id").select(:type) + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + end + def test_size_with_eager_loading_and_custom_order_and_distinct posts = Post.includes(:comments).order("comments.id").distinct assert_queries(1) { assert_equal 11, posts.size } assert_queries(1) { assert_equal 11, posts.load.size } end - def test_update_all_with_scope - tag = Tag.first - Post.tagged_with(tag.id).update_all title: "rofl" - list = Post.tagged_with(tag.id).all.to_a - assert_operator list.length, :>, 0 - list.each { |post| assert_equal "rofl", post.title } + def test_size_with_eager_loading_and_manual_distinct_select_and_custom_order + accounts = Account.select("DISTINCT accounts.firm_id").order("accounts.firm_id") + + assert_queries(1) { assert_equal 5, accounts.size } + assert_queries(1) { assert_equal 5, accounts.load.size } end def test_count_explicit_columns @@ -1097,7 +1111,7 @@ class RelationTest < ActiveRecord::TestCase 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_predicate posts, :loaded? @@ -1109,7 +1123,7 @@ 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_predicate posts, :loaded? @@ -1125,14 +1139,14 @@ class RelationTest < ActiveRecord::TestCase def test_none? posts = Post.all assert_queries(1) do - assert ! posts.none? # Uses COUNT() + assert_not posts.none? # Uses COUNT() end 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_predicate posts, :loaded? @@ -1141,13 +1155,13 @@ class RelationTest < ActiveRecord::TestCase def test_one posts = Post.all assert_queries(1) do - assert ! posts.one? # Uses COUNT() + assert_not posts.one? # Uses COUNT() end 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 @@ -1231,8 +1245,23 @@ class RelationTest < ActiveRecord::TestCase assert_equal "green", parrot.color end + def test_first_or_create_with_after_initialize + Bird.create!(color: "yellow", name: "canary") + parrot = assert_deprecated do + Bird.where(color: "green").first_or_create do |bird| + bird.name = "parrot" + bird.enable_count = true + end + end + assert_equal 0, parrot.total_count + end + def test_first_or_create_with_block - parrot = Bird.where(color: "green").first_or_create { |bird| bird.name = "parrot" } + Bird.create!(color: "yellow", name: "canary") + parrot = Bird.where(color: "green").first_or_create do |bird| + bird.name = "parrot" + assert_deprecated { assert_equal 0, Bird.count } + end assert_kind_of Bird, parrot assert_predicate parrot, :persisted? assert_equal "green", parrot.color @@ -1273,8 +1302,23 @@ class RelationTest < ActiveRecord::TestCase assert_raises(ActiveRecord::RecordInvalid) { Bird.where(color: "green").first_or_create! } end + def test_first_or_create_bang_with_after_initialize + Bird.create!(color: "yellow", name: "canary") + parrot = assert_deprecated do + Bird.where(color: "green").first_or_create! do |bird| + bird.name = "parrot" + bird.enable_count = true + end + end + assert_equal 0, parrot.total_count + end + def test_first_or_create_bang_with_valid_block - parrot = Bird.where(color: "green").first_or_create! { |bird| bird.name = "parrot" } + Bird.create!(color: "yellow", name: "canary") + parrot = Bird.where(color: "green").first_or_create! do |bird| + bird.name = "parrot" + assert_deprecated { assert_equal 0, Bird.count } + end assert_kind_of Bird, parrot assert_predicate parrot, :persisted? assert_equal "green", parrot.color @@ -1323,8 +1367,23 @@ class RelationTest < ActiveRecord::TestCase assert_equal "green", parrot.color end + def test_first_or_initialize_with_after_initialize + Bird.create!(color: "yellow", name: "canary") + parrot = assert_deprecated do + Bird.where(color: "green").first_or_initialize do |bird| + bird.name = "parrot" + bird.enable_count = true + end + end + assert_equal 0, parrot.total_count + end + def test_first_or_initialize_with_block - parrot = Bird.where(color: "green").first_or_initialize { |bird| bird.name = "parrot" } + Bird.create!(color: "yellow", name: "canary") + parrot = Bird.where(color: "green").first_or_initialize do |bird| + bird.name = "parrot" + assert_deprecated { assert_equal 0, Bird.count } + end assert_kind_of Bird, parrot assert_not_predicate parrot, :persisted? assert_predicate parrot, :valid? @@ -1365,6 +1424,13 @@ class RelationTest < ActiveRecord::TestCase assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat") end + def test_create_or_find_by_should_not_raise_due_to_validation_errors + assert_nothing_raised do + bird = Bird.create_or_find_by(color: "green") + assert_predicate bird, :invalid? + end + end + def test_create_or_find_by_with_non_unique_attributes Subscriber.create!(nick: "bob", name: "the builder") @@ -1384,6 +1450,38 @@ class RelationTest < ActiveRecord::TestCase end end + def test_create_or_find_by_with_bang + 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_bang_should_raise_due_to_validation_errors + assert_raises(ActiveRecord::RecordInvalid) { Bird.create_or_find_by!(color: "green") } + end + + def test_create_or_find_by_with_bang_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_with_bang_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") @@ -1402,15 +1500,27 @@ 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 author_posts = relation.except(:order, :limit) - assert_equal Post.where(author_id: 1).to_a, author_posts.to_a + assert_equal Post.where(author_id: 1).sort_by(&:id), author_posts.sort_by(&:id) + assert_equal author_posts.sort_by(&:id), relation.scoping { Post.except(:order, :limit).sort_by(&:id) } all_posts = relation.except(:where, :order, :limit) - assert_equal Post.all, all_posts + assert_equal Post.all.sort_by(&:id), all_posts.sort_by(&:id) + assert_equal all_posts.sort_by(&:id), relation.scoping { Post.except(:where, :order, :limit).sort_by(&:id) } end def test_only @@ -1418,10 +1528,12 @@ class RelationTest < ActiveRecord::TestCase assert_equal [posts(:welcome)], relation.to_a author_posts = relation.only(:where) - assert_equal Post.where(author_id: 1).to_a, author_posts.to_a + assert_equal Post.where(author_id: 1).sort_by(&:id), author_posts.sort_by(&:id) + assert_equal author_posts.sort_by(&:id), relation.scoping { Post.only(:where).sort_by(&:id) } - all_posts = relation.only(:limit) - assert_equal Post.limit(1).to_a, all_posts.to_a + all_posts = relation.only(:order) + assert_equal Post.order("id ASC").to_a, all_posts.to_a + assert_equal all_posts.to_a, relation.scoping { Post.only(:order).to_a } end def test_anonymous_extension @@ -1483,68 +1595,6 @@ class RelationTest < ActiveRecord::TestCase assert_equal authors(:david), Author.order("id DESC , name DESC").last end - def test_update_all_with_blank_argument - assert_raises(ArgumentError) { Comment.update_all({}) } - end - - def test_update_all_with_joins - comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id) - count = comments.count - - assert_equal count, comments.update_all(post_id: posts(:thinking).id) - assert_equal posts(:thinking), comments(:greetings).post - end - - def test_update_all_with_joins_and_limit - comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).limit(1) - assert_equal 1, comments.update_all(post_id: posts(:thinking).id) - end - - def test_update_all_with_joins_and_limit_and_order - comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).order("comments.id").limit(1) - assert_equal 1, comments.update_all(post_id: posts(:thinking).id) - assert_equal posts(:thinking), comments(:greetings).post - assert_equal posts(:welcome), comments(:more_greetings).post - end - - def test_update_all_with_joins_and_offset - all_comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id) - count = all_comments.count - comments = all_comments.offset(1) - - assert_equal count - 1, comments.update_all(post_id: posts(:thinking).id) - end - - def test_update_all_with_joins_and_offset_and_order - all_comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).order("posts.id", "comments.id") - count = all_comments.count - comments = all_comments.offset(1) - - assert_equal count - 1, comments.update_all(post_id: posts(:thinking).id) - assert_equal posts(:thinking), comments(:more_greetings).post - assert_equal posts(:welcome), comments(:greetings).post - 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 "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_on_relation_passing_active_record_object_is_not_permitted - topic = Topic.create!(title: "Foo", author_name: nil) - assert_raises(ArgumentError) do - Topic.where(id: topic.id).update(topic, title: "Bar") - end - end - def test_distinct tag1 = Tag.create(name: "Foo") tag2 = Tag.create(name: "Foo") @@ -1696,7 +1746,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 } @@ -1709,6 +1759,24 @@ class RelationTest < ActiveRecord::TestCase assert_predicate topics, :loaded? end + def test_delete_by + david = authors(:david) + + assert_difference("Post.count", -3) { david.posts.delete_by(body: "hello") } + + deleted = Author.delete_by(id: david.id) + assert_equal 1, deleted + end + + def test_destroy_by + david = authors(:david) + + assert_difference("Post.count", -3) { david.posts.destroy_by(body: "hello") } + + destroyed = Author.destroy_by(id: david.id) + assert_equal [david], destroyed + end + test "find_by with hash conditions returns the first matching record" do assert_equal posts(:eager_other), Post.order(:id).find_by(author_id: 2) end @@ -1878,6 +1946,19 @@ class RelationTest < ActiveRecord::TestCase assert_equal [1, 1, 1], posts.map(&:author_address_id) end + test "joins with select custom attribute" do + contract = Company.create!(name: "test").contracts.create! + company = Company.joins(:contracts).select(:id, :metadata).find(contract.company_id) + assert_equal contract.metadata, company.metadata + end + + test "joins with order by custom attribute" do + companies = Company.create!([{ name: "test1" }, { name: "test2" }]) + companies.each { |company| company.contracts.create! } + assert_equal companies, Company.joins(:contracts).order(:metadata) + assert_equal companies.reverse, Company.joins(:contracts).order(metadata: :desc) + end + test "delegations do not leak to other classes" do Topic.all.by_lifo assert Topic.all.class.method_defined?(:by_lifo) @@ -1896,6 +1977,30 @@ class RelationTest < ActiveRecord::TestCase assert_equal p2.first.comments, comments end + def test_unscope_with_merge + p0 = Post.where(author_id: 0) + p1 = Post.where(author_id: 1, comments_count: 1) + + assert_equal [posts(:authorless)], p0 + assert_equal [posts(:thinking)], p1 + + comments = Comment.merge(p0).unscope(where: :author_id).where(post: p1) + + assert_not_equal p0.first.comments, comments + assert_equal p1.first.comments, comments + end + + def test_unscope_with_unknown_column + comment = comments(:greetings) + comment.update!(comments: 1) + + comments = Comment.where(comments: 1).unscope(where: :unknown_column) + assert_equal [comment], comments + + comments = Comment.where(comments: 1).unscope(where: { comments: :unknown_column }) + assert_equal [comment], comments + end + def test_unscope_specific_where_value posts = Post.where(title: "Welcome to the weblog", body: "Such a lovely day") @@ -1914,6 +2019,16 @@ class RelationTest < ActiveRecord::TestCase assert_equal "Thank you for the welcome,Thank you again for the welcome", Post.first.comments.join(",") end + def test_relation_with_private_kernel_method + accounts = Account.all + assert_equal [accounts(:signals37)], accounts.open + assert_equal [accounts(:signals37)], accounts.available + + sub_accounts = SubAccount.all + assert_equal [accounts(:signals37)], sub_accounts.open + assert_equal [accounts(:signals37)], sub_accounts.available + end + test "#skip_query_cache!" do Post.cache do assert_queries(1) do diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb index db52c108ac..825aee2423 100644 --- a/activerecord/test/cases/result_test.rb +++ b/activerecord/test/cases/result_test.rb @@ -12,16 +12,31 @@ 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 - test "to_hash returns row_hashes" do + test "to_a returns row_hashes" do assert_equal [ { "col_1" => "row 1 col 1", "col_2" => "row 1 col 2" }, { "col_1" => "row 2 col 1", "col_2" => "row 2 col 2" }, { "col_1" => "row 3 col 1", "col_2" => "row 3 col 2" }, - ], result.to_hash + ], result.to_a + end + + test "to_hash (deprecated) returns row_hashes" do + assert_deprecated do + assert_equal [ + { "col_1" => "row 1 col 1", "col_2" => "row 1 col 2" }, + { "col_1" => "row 2 col 1", "col_2" => "row 2 col 2" }, + { "col_1" => "row 3 col 1", "col_2" => "row 3 col 2" }, + ], result.to_hash + end end test "first returns first row as a hash" do diff --git a/activerecord/test/cases/sanitize_test.rb b/activerecord/test/cases/sanitize_test.rb index 778cf86ac3..6c884b4f45 100644 --- a/activerecord/test/cases/sanitize_test.rb +++ b/activerecord/test/cases/sanitize_test.rb @@ -148,6 +148,19 @@ class SanitizeTest < ActiveRecord::TestCase assert_equal "foo in (#{quoted_nil})", bind("foo in (?)", []) end + def test_bind_range + quoted_abc = %(#{ActiveRecord::Base.connection.quote('a')},#{ActiveRecord::Base.connection.quote('b')},#{ActiveRecord::Base.connection.quote('c')}) + assert_equal "0", bind("?", 0..0) + assert_equal "1,2,3", bind("?", 1..3) + assert_equal quoted_abc, bind("?", "a"..."d") + end + + def test_bind_empty_range + quoted_nil = ActiveRecord::Base.connection.quote(nil) + assert_equal quoted_nil, bind("?", 0...0) + assert_equal quoted_nil, bind("?", "a"..."a") + end + def test_bind_empty_string quoted_empty = ActiveRecord::Base.connection.quote("") assert_equal quoted_empty, bind("?", "") @@ -168,12 +181,6 @@ class SanitizeTest < ActiveRecord::TestCase 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 31bdf3f357..49e9be9565 100644 --- a/activerecord/test/cases/schema_dumper_test.rb +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -37,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 @@ -192,7 +174,7 @@ 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 @@ -244,27 +226,50 @@ class SchemaDumperTest < ActiveRecord::TestCase assert_match %r{t\.float\s+"temperature"$}, output end + if ActiveRecord::Base.connection.supports_expression_index? + def test_schema_dump_expression_indices + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_expression_index/).first.strip + index_definition.sub!(/, name: "company_expression_index"\z/, "") + + if current_adapter?(:PostgreSQLAdapter) + assert_match %r{CASE.+lower\(\(name\)::text\).+END\) DESC"\z}i, index_definition + elsif current_adapter?(:Mysql2Adapter) + assert_match %r{CASE.+lower\(`name`\).+END\) DESC"\z}i, index_definition + elsif current_adapter?(:SQLite3Adapter) + assert_match %r{CASE.+lower\(name\).+END\) DESC"\z}i, index_definition + else + assert false + end + end + end + if current_adapter?(:Mysql2Adapter) def test_schema_dump_includes_length_for_mysql_binary_fields - output = standard_dump + output = dump_table_schema "binary_fields" assert_match %r{t\.binary\s+"var_binary",\s+limit: 255$}, output assert_match %r{t\.binary\s+"var_binary_large",\s+limit: 4095$}, output end def test_schema_dump_includes_length_for_mysql_blob_and_text_fields - output = standard_dump - assert_match %r{t\.blob\s+"tiny_blob",\s+limit: 255$}, output + output = dump_table_schema "binary_fields" + assert_match %r{t\.binary\s+"tiny_blob",\s+size: :tiny$}, output assert_match %r{t\.binary\s+"normal_blob"$}, output - assert_match %r{t\.binary\s+"medium_blob",\s+limit: 16777215$}, output - assert_match %r{t\.binary\s+"long_blob",\s+limit: 4294967295$}, output - assert_match %r{t\.text\s+"tiny_text",\s+limit: 255$}, output + assert_match %r{t\.binary\s+"medium_blob",\s+size: :medium$}, output + assert_match %r{t\.binary\s+"long_blob",\s+size: :long$}, output + assert_match %r{t\.text\s+"tiny_text",\s+size: :tiny$}, output assert_match %r{t\.text\s+"normal_text"$}, output - assert_match %r{t\.text\s+"medium_text",\s+limit: 16777215$}, output - assert_match %r{t\.text\s+"long_text",\s+limit: 4294967295$}, output + assert_match %r{t\.text\s+"medium_text",\s+size: :medium$}, output + assert_match %r{t\.text\s+"long_text",\s+size: :long$}, output + assert_match %r{t\.binary\s+"tiny_blob_2",\s+size: :tiny$}, output + assert_match %r{t\.binary\s+"medium_blob_2",\s+size: :medium$}, output + assert_match %r{t\.binary\s+"long_blob_2",\s+size: :long$}, output + assert_match %r{t\.text\s+"tiny_text_2",\s+size: :tiny$}, output + assert_match %r{t\.text\s+"medium_text_2",\s+size: :medium$}, output + assert_match %r{t\.text\s+"long_text_2",\s+size: :long$}, output end def test_schema_does_not_include_limit_for_emulated_mysql_boolean_fields - output = standard_dump + output = dump_table_schema "booleans" assert_no_match %r{t\.boolean\s+"has_fun",.+limit: 1}, output end @@ -296,11 +301,6 @@ class SchemaDumperTest < ActiveRecord::TestCase assert_match %r{t\.decimal\s+"decimal_array_default",\s+default: \["1.23", "3.45"\],\s+array: true}, output end - def test_schema_dump_expression_indices - index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_expression_index/).first.strip - assert_match %r{CASE.+lower\(\(name\)::text\)}i, index_definition - end - def test_schema_dump_interval_type output = dump_table_schema "postgresql_times" assert_match %r{t\.interval\s+"time_interval"$}, output @@ -315,29 +315,33 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dump_includes_extensions connection = ActiveRecord::Base.connection - connection.stubs(:extensions).returns(["hstore"]) - output = perform_schema_dump - assert_match "# These are extensions that must be enabled", output - assert_match %r{enable_extension "hstore"}, output + 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([]) - output = perform_schema_dump - assert_no_match "# These are extensions that must be enabled", output - assert_no_match %r{enable_extension}, output + 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.stubs(:extensions).returns(["hstore", "uuid-ossp", "xml2"]) - output = perform_schema_dump - enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten - assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions + 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.stubs(:extensions).returns(["uuid-ossp", "xml2", "hstore"]) - output = perform_schema_dump - enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten - assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions + 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 diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb index 0804de1fb3..e7bdab58c6 100644 --- a/activerecord/test/cases/scoping/default_scoping_test.rb +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -4,6 +4,7 @@ require "cases/helper" require "models/post" require "models/comment" require "models/developer" +require "models/project" require "models/computer" require "models/vehicle" require "models/cat" @@ -193,7 +194,7 @@ class DefaultScopingTest < ActiveRecord::TestCase 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 @@ -366,6 +367,21 @@ class DefaultScopingTest < ActiveRecord::TestCase 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 @@ -392,18 +408,18 @@ class DefaultScopingTest < ActiveRecord::TestCase end def test_joins_not_affected_by_scope_other_than_default_or_unscoped - without_scope_on_post = Comment.joins(:post).to_a + without_scope_on_post = Comment.joins(:post).sort_by(&:id) with_scope_on_post = nil Post.where(id: [1, 5, 6]).scoping do - with_scope_on_post = Comment.joins(:post).to_a + with_scope_on_post = Comment.joins(:post).sort_by(&:id) end - assert_equal with_scope_on_post, without_scope_on_post + assert_equal without_scope_on_post, with_scope_on_post end def test_unscoped_with_joins_should_not_have_default_scope - assert_equal SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).to_a }, - Comment.joins(:post).to_a + assert_equal Comment.joins(:post).sort_by(&:id), + SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).sort_by(&:id) } end def test_sti_association_with_unscoped_not_affected_by_default_scope diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb index 03f8a4f7c9..3488442cab 100644 --- a/activerecord/test/cases/scoping/named_scoping_test.rb +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -50,7 +50,7 @@ class NamedScopingTest < ActiveRecord::TestCase def test_calling_merge_at_first_in_scope Topic.class_eval do - scope :calling_merge_at_first_in_scope, Proc.new { merge(Topic.replied) } + scope :calling_merge_at_first_in_scope, Proc.new { merge(Topic.unscoped.replied) } end assert_equal Topic.calling_merge_at_first_in_scope.to_a, Topic.replied.to_a end @@ -86,7 +86,7 @@ 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_not (approved == replied) assert_not_empty (approved & replied) assert_equal approved & replied, Topic.approved.replied @@ -303,13 +303,6 @@ 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" @@ -454,6 +447,17 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal [posts(:sti_comments)], Post.with_special_comments.with_post(4).to_a.uniq end + def test_class_method_in_scope + assert_deprecated do + assert_equal [topics(:second)], topics(:first).approved_replies.ordered + end + end + + def test_nested_scoping + expected = Reply.approved + assert_equal expected.to_a, Topic.rejected.nested_scoping(expected) + end + def test_scopes_batch_finders assert_equal 4, Topic.approved.count @@ -488,8 +492,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 @@ -597,4 +602,14 @@ class NamedScopingTest < ActiveRecord::TestCase Topic.create! assert_predicate Topic, :one? end + + def test_scope_with_annotation + Topic.class_eval do + scope :including_annotate_in_scope, Proc.new { annotate("from-scope") } + end + + assert_sql(%r{/\* from-scope \*/}) do + assert Topic.including_annotate_in_scope.to_a, Topic.all.to_a + end + end end diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb index 5c86bc892d..50b514d464 100644 --- a/activerecord/test/cases/scoping/relation_scoping_test.rb +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -105,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 @@ -130,6 +130,44 @@ class RelationScopingTest < ActiveRecord::TestCase end end + def test_scoped_find_with_annotation + Developer.annotate("scoped").scoping do + developer = nil + assert_sql(%r{/\* scoped \*/}) do + developer = Developer.where("name = 'David'").first + end + assert_equal "David", developer.name + end + end + + def test_find_with_annotation_unscoped + Developer.annotate("scoped").unscoped do + developer = nil + log = capture_sql do + developer = Developer.where("name = 'David'").first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\* scoped \*/}) }, :empty? + + assert_equal "David", developer.name + end + end + + def test_find_with_annotation_unscope + developer = nil + log = capture_sql do + developer = Developer.annotate("unscope"). + where("name = 'David'"). + unscope(:annotate).first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\* unscope \*/}) }, :empty? + + assert_equal "David", developer.name + end + def test_scoped_find_include # with the include, will retrieve only developers for the given project scoped_developers = Developer.includes(:projects).scoping do @@ -236,8 +274,8 @@ class RelationScopingTest < ActiveRecord::TestCase SpecialComment.unscoped.created end - assert_nil Comment.current_scope - assert_nil SpecialComment.current_scope + assert_nil Comment.send(:current_scope) + assert_nil SpecialComment.send(:current_scope) end def test_scoping_respects_current_class @@ -254,6 +292,16 @@ class RelationScopingTest < ActiveRecord::TestCase end end + def test_scoping_with_klass_method_works_in_the_scope_block + expected = SpecialPostWithDefaultScope.unscoped.to_a + assert_equal expected, SpecialPostWithDefaultScope.unscoped_all + end + + def test_scoping_with_query_method_works_in_the_scope_block + expected = SpecialPostWithDefaultScope.unscoped.where(author_id: 0).to_a + assert_equal expected, SpecialPostWithDefaultScope.authorless + end + def test_circular_joins_with_scoping_does_not_crash posts = Post.joins(comments: :post).scoping do Post.first(10) @@ -363,7 +411,19 @@ class HasManyScopingTest < ActiveRecord::TestCase def test_nested_scope_finder Comment.where("1=0").scoping do - assert_equal 0, @welcome.comments.count + assert_equal 2, @welcome.comments.count + assert_equal "a comment...", @welcome.comments.what_are_you + end + + Comment.where("1=1").scoping do + assert_equal 2, @welcome.comments.count + assert_equal "a comment...", @welcome.comments.what_are_you + end + end + + def test_none_scoping + Comment.none.scoping do + assert_equal 2, @welcome.comments.count assert_equal "a comment...", @welcome.comments.what_are_you end @@ -404,7 +464,19 @@ class HasAndBelongsToManyScopingTest < ActiveRecord::TestCase def test_nested_scope_finder Category.where("1=0").scoping do - assert_equal 0, @welcome.categories.count + assert_equal 2, @welcome.categories.count + assert_equal "a category...", @welcome.categories.what_are_you + end + + Category.where("1=1").scoping do + assert_equal 2, @welcome.categories.count + assert_equal "a category...", @welcome.categories.what_are_you + end + end + + def test_none_scoping + Category.none.scoping do + assert_equal 2, @welcome.categories.count assert_equal "a category...", @welcome.categories.what_are_you end diff --git a/activerecord/test/cases/serialization_test.rb b/activerecord/test/cases/serialization_test.rb index 2d829ad4ba..932780bfef 100644 --- a/activerecord/test/cases/serialization_test.rb +++ b/activerecord/test/cases/serialization_test.rb @@ -67,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 7de5429cbb..ecf81b2042 100644 --- a/activerecord/test/cases/serialized_attribute_test.rb +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -1,18 +1,23 @@ # frozen_string_literal: true require "cases/helper" -require "models/topic" -require "models/reply" require "models/person" require "models/traffic_light" require "models/post" -require "bcrypt" class SerializedAttributeTest < ActiveRecord::TestCase fixtures :topics, :posts MyObject = Struct.new :attribute1, :attribute2 + class Topic < ActiveRecord::Base + serialize :content + end + + class ImportantTopic < Topic + serialize :important, Hash + end + teardown do Topic.serialize("content") end @@ -49,10 +54,10 @@ class SerializedAttributeTest < ActiveRecord::TestCase def test_serialized_attributes_from_database_on_subclass Topic.serialize :content, Hash - t = Reply.new(content: { foo: :bar }) + t = ImportantTopic.new(content: { foo: :bar }) assert_equal({ foo: :bar }, t.content) t.save! - t = Reply.last + t = ImportantTopic.last assert_equal({ foo: :bar }, t.content) end @@ -159,6 +164,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) @@ -166,6 +178,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 @@ -308,7 +327,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 @@ -353,9 +372,9 @@ class SerializedAttributeTest < ActiveRecord::TestCase end def test_serialized_attribute_works_under_concurrent_initial_access - model = Topic.dup + model = Class.new(Topic) - topic = model.last + topic = model.create! topic.update group: "1" model.serialize :group, JSON diff --git a/activerecord/test/cases/statement_cache_test.rb b/activerecord/test/cases/statement_cache_test.rb index e3c12f68fd..6a6d73dc38 100644 --- a/activerecord/test/cases/statement_cache_test.rb +++ b/activerecord/test/cases/statement_cache_test.rb @@ -4,6 +4,7 @@ require "cases/helper" require "models/book" require "models/liquid" require "models/molecule" +require "models/numeric_data" require "models/electron" module ActiveRecord @@ -74,6 +75,11 @@ module ActiveRecord assert_equal "salty", liquids[0].name end + def test_statement_cache_with_strictly_cast_attribute + row = NumericData.create(temperature: 1.5) + assert_equal row, NumericData.find_by(temperature: 1.5) + end + def test_statement_cache_values_differ cache = ActiveRecord::StatementCache.create(Book.connection) do |params| Book.where(name: "my book") diff --git a/activerecord/test/cases/statement_invalid_test.rb b/activerecord/test/cases/statement_invalid_test.rb new file mode 100644 index 0000000000..16ea69c1bd --- /dev/null +++ b/activerecord/test/cases/statement_invalid_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" + +module ActiveRecord + class StatementInvalidTest < ActiveRecord::TestCase + fixtures :books + + class MockDatabaseError < StandardError + def result + 0 + end + + def error_number + 0 + end + end + + test "message contains no sql" do + sql = Book.where(author_id: 96, cover: "hard").to_sql + error = assert_raises(ActiveRecord::StatementInvalid) do + Book.connection.send(:log, sql, Book.name) do + raise MockDatabaseError + end + end + assert_not error.message.include?("SELECT") + end + + test "statement and binds are set on select" do + sql = Book.where(author_id: 96, cover: "hard").to_sql + binds = [Minitest::Mock.new, Minitest::Mock.new] + error = assert_raises(ActiveRecord::StatementInvalid) do + Book.connection.send(:log, sql, Book.name, binds) do + raise MockDatabaseError + end + end + assert_equal error.sql, sql + assert_equal error.binds, binds + end + end +end diff --git a/activerecord/test/cases/store_test.rb b/activerecord/test/cases/store_test.rb index 3bd480cfbd..91c0e959f4 100644 --- a/activerecord/test/cases/store_test.rb +++ b/activerecord/test/cases/store_test.rb @@ -79,6 +79,74 @@ class StoreTest < ActiveRecord::TestCase assert_not_predicate @john, :settings_changed? end + test "updating the store will mark accessor as changed" do + @john.color = "red" + assert @john.color_changed? + end + + test "new record and no accessors changes" do + user = Admin::User.new + assert_not user.color_changed? + assert_nil user.color_was + assert_nil user.color_change + + user.color = "red" + assert user.color_changed? + assert_nil user.color_was + assert_equal "red", user.color_change[1] + end + + test "updating the store won't mark accessor as changed if the whole store was updated" do + @john.settings = { color: @john.color, some: "thing" } + assert @john.settings_changed? + assert_not @john.color_changed? + end + + test "updating the store populates the accessor changed array correctly" do + @john.color = "red" + assert_equal "black", @john.color_was + assert_equal "black", @john.color_change[0] + assert_equal "red", @john.color_change[1] + end + + test "updating the store won't mark accessor as changed if the value isn't changed" do + @john.color = @john.color + assert_not @john.color_changed? + end + + test "nullifying the store mark accessor as changed" do + color = @john.color + @john.settings = nil + assert @john.color_changed? + assert_equal color, @john.color_was + assert_equal [color, nil], @john.color_change + end + + test "dirty methods for suffixed accessors" do + @john.configs[:two_factor_auth] = true + assert @john.two_factor_auth_configs_changed? + assert_nil @john.two_factor_auth_configs_was + assert_equal [nil, true], @john.two_factor_auth_configs_change + end + + test "dirty methods for prefixed accessors" do + @john.spouse[:name] = "Lena" + assert @john.partner_name_changed? + assert_equal "Dallas", @john.partner_name_was + assert_equal ["Dallas", "Lena"], @john.partner_name_change + end + + test "saved changes tracking for accessors" do + @john.spouse[:name] = "Lena" + assert @john.partner_name_changed? + + @john.save! + assert_not @john.partner_name_change + assert @john.saved_change_to_partner_name? + assert_equal ["Dallas", "Lena"], @john.saved_change_to_partner_name + assert_equal "Dallas", @john.partner_name_before_last_save + end + test "object initialization with not nullable column" do assert_equal true, @john.remember_login end @@ -214,4 +282,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 b68f0033d9..9be5356901 100644 --- a/activerecord/test/cases/suppressor_test.rb +++ b/activerecord/test/cases/suppressor_test.rb @@ -66,7 +66,7 @@ class SuppressorTest < ActiveRecord::TestCase def test_suppresses_when_nested_multiple_times assert_no_difference -> { Notification.count } do Notification.suppress do - Notification.suppress {} + Notification.suppress { } Notification.create Notification.create! Notification.new.save diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 48d1fc7eb0..dd4a0b0455 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -2,14 +2,23 @@ require "cases/helper" require "active_record/tasks/database_tasks" +require "models/author" 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 @@ -18,6 +27,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 = { @@ -28,45 +47,67 @@ module ActiveRecord class DatabaseTasksUtilsTask < ActiveRecord::TestCase def test_raises_an_error_when_called_with_protected_environment - ActiveRecord::MigrationContext.any_instance.stubs(:current_version).returns(1) - protected_environments = ActiveRecord::Base.protected_environments current_env = ActiveRecord::Base.connection.migration_context.current_environment - assert_not_includes protected_environments, current_env - # Assert no error - ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! - ActiveRecord::Base.protected_environments = [current_env] - assert_raise(ActiveRecord::ProtectedEnvironmentError) do + InternalMetadata[:environment] = current_env + + 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] + + 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_when_called_with_protected_environment_which_name_is_a_symbol - ActiveRecord::MigrationContext.any_instance.stubs(:current_version).returns(1) - protected_environments = ActiveRecord::Base.protected_environments current_env = ActiveRecord::Base.connection.migration_context.current_environment - 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 + InternalMetadata[:environment] = current_env + + 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::MigrationContext.any_instance.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 @@ -79,11 +120,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 @@ -98,8 +140,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 @@ -119,59 +164,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") + @configurations["development"]["host"] = "127.0.0.1" - ActiveRecord::Tasks::DatabaseTasks.expects(:create) - - 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) + @configurations["development"]["host"] = nil - ActiveRecord::Tasks::DatabaseTasks.expects(:create) - - 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 @@ -179,66 +253,98 @@ module ActiveRecord @configurations = { "development" => { "database" => "dev-db" }, "test" => { "database" => "test-db" }, - "production" => { "url" => "prod-db-url" } + "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" => "test-db") - - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("test") - ) + 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 def test_creates_current_environment_database_with_url - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("url" => "prod-db-url") - - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("production") - ) + 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_environments - ActiveRecord::Tasks::DatabaseTasks.stubs(:create).returns true + 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") - ) - end + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end end class DatabaseTasksCreateCurrentThreeTierTest < ActiveRecord::TestCase @@ -246,80 +352,112 @@ module ActiveRecord @configurations = { "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, - "production" => { "primary" => { "url" => "prod-db-url" }, "secondary" => { "url" => "secondary-prod-db-url" } } + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-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" => "test-db") - - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "secondary-test-db") - - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("test") - ) + 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 - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("url" => "prod-db-url") - - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("url" => "secondary-prod-db-url") - - ActiveRecord::Tasks::DatabaseTasks.create_current( - ActiveSupport::StringInquirer.new("production") - ) + 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 - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "secondary-dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "test-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "secondary-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" => "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" - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "secondary-dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "test-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:create). - with("database" => "secondary-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" => "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.stubs(:create).returns true + 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") - ) - end + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end end class DatabaseTasksDropTest < ActiveRecord::TestCase @@ -327,8 +465,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 @@ -337,57 +478,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) + @configurations[:development]["host"] = "my.server.tld" - ActiveRecord::Tasks::DatabaseTasks.expects(:drop).never - - 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") + @configurations[:development]["host"] = "127.0.0.1" - ActiveRecord::Tasks::DatabaseTasks.expects(:drop) - - 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) + @configurations[:development]["host"] = nil - ActiveRecord::Tasks::DatabaseTasks.expects(:drop) - - 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 @@ -395,55 +563,80 @@ module ActiveRecord @configurations = { "development" => { "database" => "dev-db" }, "test" => { "database" => "test-db" }, - "production" => { "url" => "prod-db-url" } + "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" => "test-db") - - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("test") - ) + 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 def test_drops_current_environment_database_with_url - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("url" => "prod-db-url") - - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("production") - ) + 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 + + 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 @@ -451,73 +644,100 @@ module ActiveRecord @configurations = { "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, - "production" => { "primary" => { "url" => "prod-db-url" }, "secondary" => { "url" => "secondary-prod-db-url" } } + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } } - - ActiveRecord::Base.stubs(:configurations).returns(@configurations) end def test_drops_current_environment_database - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "test-db") - - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "secondary-test-db") - - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("test") - ) + 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_drops_current_environment_database_with_url - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("url" => "prod-db-url") - - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("url" => "secondary-prod-db-url") - - ActiveRecord::Tasks::DatabaseTasks.drop_current( - ActiveSupport::StringInquirer.new("production") - ) + 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 - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "secondary-dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "test-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "secondary-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" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-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" => "secondary-dev-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "test-db") - ActiveRecord::Tasks::DatabaseTasks.expects(:drop). - with("database" => "secondary-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" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end ensure ENV["RAILS_ENV"] = old_env end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end end if current_adapter?(:SQLite3Adapter) && !in_memory_db? - class DatabaseTasksMigrateTest < ActiveRecord::TestCase + class DatabaseTasksMigrationTestCase < ActiveRecord::TestCase self.use_transactional_tests = false # Use a memory db here to avoid having to rollback at the end @@ -537,7 +757,9 @@ module ActiveRecord @conn.release_connection if @conn ActiveRecord::Base.establish_connection :arunit end + end + class DatabaseTasksMigrateTest < DatabaseTasksMigrationTestCase def test_migrate_set_and_unset_verbose_and_version_env_vars verbose, version = ENV["VERBOSE"], ENV["VERSION"] ENV["VERSION"] = "2" @@ -598,6 +820,26 @@ module ActiveRecord end end end + + class DatabaseTasksMigrateStatusTest < DatabaseTasksMigrationTestCase + def test_migrate_status_table + ActiveRecord::SchemaMigration.create_table + output = capture_migration_status + assert_match(/database: :memory:/, output) + assert_match(/down 001 Valid people have last names/, output) + assert_match(/down 002 We need reminders/, output) + assert_match(/down 003 Innocent jointable/, output) + ActiveRecord::SchemaMigration.drop_table + end + + private + + def capture_migration_status + capture(:stdout) do + ActiveRecord::Tasks::DatabaseTasks.migrate_status + end + end + end end class DatabaseTasksMigrateErrorTest < ActiveRecord::TestCase @@ -638,15 +880,16 @@ module ActiveRecord end def test_migrate_raise_error_on_failed_check_target_version - ActiveRecord::Tasks::DatabaseTasks.stubs(:check_target_version).raises("foo") - - e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } - assert_equal "foo", e.message + 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 @@ -655,48 +898,238 @@ 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::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 + + unless in_memory_db? + class DatabaseTasksTruncateAllTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :authors, :author_addresses + + def setup + SchemaMigration.create_table + SchemaMigration.create!(version: SchemaMigration.table_name) + InternalMetadata.create_table + InternalMetadata.create!(key: InternalMetadata.table_name) + end + + def teardown + SchemaMigration.delete_all + InternalMetadata.delete_all + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end - ActiveRecord::Tasks::DatabaseTasks.expects(:purge). - with("database" => "my-db") + def test_truncate_tables + assert_operator SchemaMigration.count, :>, 0 + assert_operator InternalMetadata.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 - ActiveRecord::Tasks::DatabaseTasks.purge_all + old_configurations = ActiveRecord::Base.configurations + configurations = { development: ActiveRecord::Base.configurations["arunit"] } + ActiveRecord::Base.configurations = configurations + + ActiveRecord::Tasks::DatabaseTasks.stub(:root, nil) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("development") + ) + end + + assert_operator SchemaMigration.count, :>, 0 + assert_operator InternalMetadata.count, :>, 0 + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksTruncateAllWithPrefixTest < DatabaseTasksTruncateAllTest + setup do + ActiveRecord::Base.table_name_prefix = "p_" + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + + teardown do + ActiveRecord::Base.table_name_prefix = nil + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + end + + class DatabaseTasksTruncateAllWithSuffixTest < DatabaseTasksTruncateAllTest + setup do + ActiveRecord::Base.table_name_suffix = "_s" + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + + teardown do + ActiveRecord::Base.table_name_suffix = nil + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end end end + class DatabaseTasksTruncateAllWithMultipleDatabasesTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, + "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } + } + end + + def test_truncate_all_databases_for_environment + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_truncate_all_databases_with_url_for_environment + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_truncate_all_development_databases_when_env_is_not_specified + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_truncate_all_development_databases_when_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :truncate_tables, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.truncate_all( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + class DatabaseTasksCharsetTest < ActiveRecord::TestCase include DatabaseTasksSetupper 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 @@ -706,8 +1139,11 @@ 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 @@ -819,8 +1255,14 @@ module ActiveRecord 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 @@ -830,31 +1272,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 047153e7cc..552e623fd4 100644 --- a/activerecord/test/cases/tasks/mysql_rake_test.rb +++ b/activerecord/test/cases/tasks/mysql_rake_test.rb @@ -7,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 @@ -25,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 MysqlDBCreateWithInvalidPermissionsTest < ActiveRecord::TestCase def setup - @connection = stub("Connection", create_database: true) @error = Mysql2::Error.new("Invalid permissions") @configuration = { "adapter" => "mysql2", @@ -85,10 +119,6 @@ if current_adapter?(:Mysql2Adapter) "username" => "pat", "password" => "wossname" } - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).raises(@error) - $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr end @@ -98,23 +128,21 @@ if current_adapter?(:Mysql2Adapter) end def test_raises_error - assert_raises(Mysql2::Error) do - ActiveRecord::Tasks::DatabaseTasks.create @configuration + ActiveRecord::Base.stub(:establish_connection, -> * { raise @error }) do + assert_raises(Mysql2::Error, "Invalid permissions") do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end end 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 @@ -124,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(@connection, :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end end end @@ -222,9 +289,14 @@ 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 @@ -240,41 +312,59 @@ if current_adapter?(:Mysql2Adapter) def test_structure_dump_with_ignore_tables filename = "awesome-file.sql" - ActiveRecord::SchemaDumper.expects(:ignore_tables).returns(["foo", "bar"]) - - Kernel.expects(:system).with("mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "--ignore-table=test-db.foo", "--ignore-table=test-db.bar", "test-db").returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + 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 def test_warn_when_external_structure_dump_command_execution_fails filename = "awesome-file.sql" - Kernel.expects(:system) - .with("mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db") - .returns(false) - - e = assert_raise(RuntimeError) { - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) - } - assert_match(/^failed to execute: `mysqldump`$/, e.message) + 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 diff --git a/activerecord/test/cases/tasks/postgresql_rake_test.rb b/activerecord/test/cases/tasks/postgresql_rake_test.rb index ca1defa332..065ba7734c 100644 --- a/activerecord/test/cases/tasks/postgresql_rake_test.rb +++ b/activerecord/test/cases/tasks/postgresql_rake_test.rb @@ -7,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 @@ -25,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 @@ -110,125 +165,197 @@ 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(schema_search_path: nil, structure_dump: true) @configuration = { "adapter" => "postgresql", "database" => "my-app-db" } @filename = "/tmp/awesome-file.sql" FileUtils.touch(@filename) - - ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection).returns(true) end def teardown @@ -236,18 +363,23 @@ if current_adapter?(:PostgreSQLAdapter) end def test_structure_dump - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db").returns(true) - - 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 + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end end def test_structure_dump_header_comments_removed - Kernel.stubs(:system).returns(true) - File.write(@filename, "-- header comment\n\n-- more header comment\n statement \n-- lower comment\n") - - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + 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) - assert_equal [" statement \n", "-- lower comment\n"], File.readlines(@filename).first(2) + assert_equal [" statement \n", "-- lower comment\n"], File.readlines(@filename).first(2) + end end def test_structure_dump_with_extra_flags @@ -261,47 +393,76 @@ if current_adapter?(:PostgreSQLAdapter) end def test_structure_dump_with_ignore_tables - ActiveRecord::SchemaDumper.expects(:ignore_tables).returns(["foo", "bar"]) - - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", @filename, "-T", "foo", "-T", "bar", "my-app-db").returns(true) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + 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) - - with_dump_schemas("foo,bar") do - 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 + with_dump_schemas("foo,bar") do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end end end def test_structure_dump_execution_fails filename = "awesome-file.sql" - Kernel.expects(:system).with("pg_dump", "-s", "-x", "-O", "-f", filename, "my-app-db").returns(nil) - - e = assert_raise(RuntimeError) do - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + 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 - assert_match("failed to execute:", e.message) end private @@ -324,26 +485,27 @@ 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) 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", "-X", "-f", filename, @configuration["database"]], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end end def test_structure_load_with_extra_flags filename = "awesome-file.sql" - expected_command = ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-f", filename, "--noop", @configuration["database"]] + expected_command = ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, "--noop", @configuration["database"]] assert_called_with(Kernel, :system, expected_command, returns: true) do with_structure_load_flags(["--noop"]) do @@ -354,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", "-X", "-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 d7e3caa2ff..c1092b97c1 100644 --- a/activerecord/test/cases/tasks/sqlite_rake_test.rb +++ b/activerecord/test/cases/tasks/sqlite_rake_test.rb @@ -9,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 @@ -28,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 @@ -95,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 @@ -204,9 +196,9 @@ if current_adapter?(:SQLite3Adapter) def test_structure_dump_with_ignore_tables dbfile = @database filename = "awesome-file.sql" - ActiveRecord::SchemaDumper.expects(:ignore_tables).returns(["foo"]) - - ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") + 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)) @@ -219,14 +211,19 @@ if current_adapter?(:SQLite3Adapter) def test_structure_dump_execution_fails dbfile = @database filename = "awesome-file.sql" - Kernel.expects(:system).with("sqlite3", "--noop", "db_create.sqlite3", ".schema", out: "awesome-file.sql").returns(nil) - - e = assert_raise(RuntimeError) do - with_structure_dump_flags(["--noop"]) do - quietly { ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") } + 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 - assert_match("failed to execute:", e.message) ensure FileUtils.rm_f(filename) FileUtils.rm_f(dbfile) diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb index 024b5bd8a1..5b25432dc0 100644 --- a/activerecord/test/cases/test_case.rb +++ b/activerecord/test/cases/test_case.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "active_support/test_case" +require "active_support" require "active_support/testing/autorun" require "active_support/testing/method_call_assertions" require "active_support/testing/stream" @@ -31,6 +31,7 @@ module ActiveRecord end def capture_sql + ActiveRecord::Base.connection.materialize_transactions SQLCounter.clear_log yield SQLCounter.log_all.dup @@ -48,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 @@ -77,10 +79,6 @@ 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 diff --git a/activerecord/test/cases/time_precision_test.rb b/activerecord/test/cases/time_precision_test.rb index 086500de38..1abd857216 100644 --- a/activerecord/test/cases/time_precision_test.rb +++ b/activerecord/test/cases/time_precision_test.rb @@ -45,6 +45,26 @@ if subsecond_precision_supported? assert_equal 123456000, foo.finish.nsec end + unless current_adapter?(:Mysql2Adapter) + def test_no_time_precision_isnt_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :start, :time + @connection.add_column :foos, :finish, :time, precision: 6 + + time = ::Time.now.change(nsec: 123) + foo = Foo.new(start: time, finish: time) + + assert_equal 123, foo.start.nsec + assert_equal 0, foo.finish.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.start.nsec + assert_equal 0, foo.finish.nsec + end + end + def test_passing_precision_to_time_does_not_set_limit @connection.create_table(:foos, force: true) do |t| t.time :start, precision: 3 @@ -55,7 +75,7 @@ if subsecond_precision_supported? end def test_invalid_time_precision_raises_error - assert_raises ActiveRecord::ActiveRecordError do + assert_raises ArgumentError do @connection.create_table(:foos, force: true) do |t| t.time :start, precision: 7 t.time :finish, precision: 7 diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb index d9f7a81ce4..75ecd6fc40 100644 --- a/activerecord/test/cases/timestamp_test.rb +++ b/activerecord/test/cases/timestamp_test.rb @@ -90,8 +90,8 @@ 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 diff --git a/activerecord/test/cases/touch_later_test.rb b/activerecord/test/cases/touch_later_test.rb index 925a4609a2..f1a9cf2d05 100644 --- a/activerecord/test/cases/touch_later_test.rb +++ b/activerecord/test/cases/touch_later_test.rb @@ -10,7 +10,7 @@ require "models/tree" class TouchLaterTest < ActiveRecord::TestCase fixtures :nodes, :trees - def test_touch_laster_raise_if_non_persisted + def test_touch_later_raise_if_non_persisted invoice = Invoice.new Invoice.transaction do assert_not_predicate invoice, :persisted? @@ -100,7 +100,7 @@ class TouchLaterTest < ActiveRecord::TestCase def test_touch_later_dont_hit_the_db invoice = Invoice.create! - assert_queries(0) do + assert_no_queries do invoice.touch_later end end diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb index e89ac53732..e88d20a453 100644 --- a/activerecord/test/cases/transaction_callbacks_test.rb +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -38,6 +38,7 @@ class TransactionCallbacksTest < ActiveRecord::TestCase before_commit { |record| record.do_before_commit(nil) } after_commit { |record| record.do_after_commit(nil) } + after_save_commit { |record| record.do_after_commit(:save) } after_create_commit { |record| record.do_after_commit(:create) } after_update_commit { |record| record.do_after_commit(:update) } after_destroy_commit { |record| record.do_after_commit(:destroy) } @@ -110,6 +111,17 @@ class TransactionCallbacksTest < ActiveRecord::TestCase assert_equal [:after_commit], @first.history end + def test_only_call_after_commit_on_save_after_transaction_commits_for_saving_record + record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) + record.after_commit_block(:save) { |r| r.history << :after_save } + + record.save! + assert_equal [:after_save], record.history + + record.update!(title: "Another topic") + assert_equal [:after_save, :after_save], record.history + end + def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record add_transaction_execution_blocks @first @@ -139,6 +151,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 @@ -367,6 +396,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 @@ -554,6 +603,17 @@ class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase assert_equal [:before_commit, :after_commit], @topic.history end + def test_commit_run_transactions_callbacks_with_nested_transactions + @topic.transaction do + @topic.transaction(requires_new: true) do + @topic.content = "foo" + @topic.save! + @topic.class.connection.add_transaction_record(@topic) + end + end + assert_equal [:before_commit, :after_commit], @topic.history + end + def test_rollback_does_not_run_transactions_callbacks_without_enrollment @topic.transaction do @topic.content = "foo" diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index eaafd13360..2932969412 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -11,7 +11,7 @@ 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 @@ -90,7 +90,7 @@ else test "setting isolation when joining a transaction raises an error" do Tag.transaction do assert_raises(ActiveRecord::TransactionIsolationError) do - Tag.transaction(isolation: :serializable) {} + Tag.transaction(isolation: :serializable) { } end end end @@ -98,7 +98,7 @@ else test "setting isolation when starting a nested transaction raises error" do Tag.transaction do assert_raises(ActiveRecord::TransactionIsolationError) do - Tag.transaction(requires_new: true, isolation: :serializable) {} + Tag.transaction(requires_new: true, isolation: :serializable) { } end end end diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb index 1c144a781d..410f07d3ab 100644 --- a/activerecord/test/cases/transactions_test.rb +++ b/activerecord/test/cases/transactions_test.rb @@ -34,20 +34,21 @@ class TransactionTest < ActiveRecord::TestCase end } - assert @first.reload assert_not_predicate @first, :frozen? end def test_successful - Topic.transaction do - @first.approved = true - @second.approved = false - @first.save - @second.save + assert_not_called(@first, :committed!) do + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end end - assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_predicate Topic.find(1), :approved?, "First should have been approved" + assert_not_predicate Topic.find(2), :approved?, "Second should have been unapproved" end def transaction_with_return @@ -76,11 +77,13 @@ class TransactionTest < ActiveRecord::TestCase end end - transaction_with_return + assert_not_called(@first, :committed!) do + transaction_with_return + end assert committed - assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_predicate Topic.find(1), :approved?, "First should have been approved" + assert_not_predicate Topic.find(2), :approved?, "Second should have been unapproved" ensure Topic.connection.class_eval do remove_method :commit_db_transaction @@ -99,9 +102,11 @@ class TransactionTest < ActiveRecord::TestCase end end - Topic.transaction do - @first.approved = true - @first.save! + assert_not_called(@first, :committed!) do + Topic.transaction do + @first.approved = true + @first.save! + end end assert_equal 0, num @@ -113,19 +118,21 @@ class TransactionTest < ActiveRecord::TestCase end def test_successful_with_instance_method - @first.transaction do - @first.approved = true - @second.approved = false - @first.save - @second.save + assert_not_called(@first, :committed!) do + @first.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end end - assert Topic.find(1).approved?, "First should have been approved" - assert !Topic.find(2).approved?, "Second should have been unapproved" + assert_predicate Topic.find(1), :approved?, "First should have been approved" + assert_not_predicate Topic.find(2), :approved?, "Second should have been unapproved" end def test_failing_on_exception - begin + assert_not_called(@first, :rolledback!) do Topic.transaction do @first.approved = true @second.approved = false @@ -137,11 +144,11 @@ class TransactionTest < ActiveRecord::TestCase # caught it end - assert @first.approved?, "First should still be changed in the objects" - assert !@second.approved?, "Second should still be changed in the objects" + assert_predicate @first, :approved?, "First should still be changed in the objects" + assert_not_predicate @second, :approved?, "Second should still be changed in the objects" - assert !Topic.find(1).approved?, "First shouldn't have been approved" - assert Topic.find(2).approved?, "Second should still be approved" + assert_not_predicate Topic.find(1), :approved?, "First shouldn't have been approved" + assert_predicate Topic.find(2), :approved?, "Second should still be approved" end def test_raising_exception_in_callback_rollbacks_in_save @@ -150,8 +157,10 @@ class TransactionTest < ActiveRecord::TestCase end @first.approved = true - e = assert_raises(RuntimeError) { @first.save } - assert_equal "Make the transaction rollback", e.message + assert_not_called(@first, :rolledback!) do + e = assert_raises(RuntimeError) { @first.save } + assert_equal "Make the transaction rollback", e.message + end assert_not_predicate Topic.find(1), :approved? end @@ -159,13 +168,15 @@ class TransactionTest < ActiveRecord::TestCase def @first.before_save_for_transaction raise ActiveRecord::Rollback end - assert !@first.approved + assert_not_predicate @first, :approved? - Topic.transaction do - @first.approved = true - @first.save! + assert_not_called(@first, :rolledback!) do + Topic.transaction do + @first.approved = true + @first.save! + end end - assert !Topic.find(@first.id).approved?, "Should not commit the approved flag" + assert_not_predicate Topic.find(@first.id), :approved?, "Should not commit the approved flag" end def test_raising_exception_in_nested_transaction_restore_state_in_save @@ -175,11 +186,13 @@ class TransactionTest < ActiveRecord::TestCase raise "Make the transaction rollback" end - assert_raises(RuntimeError) do - Topic.transaction { topic.save } + assert_not_called(topic, :rolledback!) do + assert_raises(RuntimeError) do + Topic.transaction { topic.save } + end end - assert topic.new_record?, "#{topic.inspect} should be new record" + assert_predicate topic, :new_record?, "#{topic.inspect} should be new record" end def test_transaction_state_is_cleared_when_record_is_persisted @@ -194,7 +207,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 @@ -212,7 +225,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 @@ -224,7 +237,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 @@ -288,7 +301,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 @@ -303,7 +328,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_nested_transaction_with_new_transaction_applies_parent_state_on_rollback @@ -387,9 +412,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 @@ -561,10 +586,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 @@ -576,12 +600,12 @@ class TransactionTest < ActiveRecord::TestCase def test_rollback_when_saving_a_frozen_record topic = Topic.new(title: "test") topic.freeze - e = assert_raise(frozen_error_class) { topic.save } + e = assert_raise(FrozenError) { 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 @@ -608,9 +632,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 @@ -641,15 +665,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 @@ -667,6 +691,36 @@ class TransactionTest < ActiveRecord::TestCase 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 @@ -843,17 +897,6 @@ class TransactionTest < ActiveRecord::TestCase 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 @@ -889,7 +932,7 @@ class TransactionTest < ActiveRecord::TestCase klass = Class.new(ActiveRecord::Base) do self.table_name = "transaction_without_primary_keys" - after_commit {} # necessary to trigger the has_transactional_callbacks branch + after_commit { } # necessary to trigger the has_transactional_callbacks branch end assert_no_difference(-> { klass.count }) do @@ -902,6 +945,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| diff --git a/activerecord/test/cases/type/time_test.rb b/activerecord/test/cases/type/time_test.rb new file mode 100644 index 0000000000..1a2c47479f --- /dev/null +++ b/activerecord/test/cases/type/time_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +module ActiveRecord + module Type + class TimeTest < ActiveRecord::TestCase + def test_default_year_is_correct + expected_time = ::Time.utc(2000, 1, 1, 10, 30, 0) + topic = Topic.new(bonus_time: { 4 => 10, 5 => 30 }) + + assert_equal expected_time, topic.bonus_time + + topic.save! + topic.reload + + assert_equal expected_time, topic.bonus_time + end + end + end +end diff --git a/activerecord/test/cases/type/type_map_test.rb b/activerecord/test/cases/type/type_map_test.rb index f3699c11a2..1ce515a90c 100644 --- a/activerecord/test/cases/type/type_map_test.rb +++ b/activerecord/test/cases/type/type_map_test.rb @@ -32,7 +32,7 @@ module ActiveRecord end def test_fuzzy_lookup - string = String.new + string = +"" mapping = TypeMap.new mapping.register_type(/varchar/i, string) @@ -41,7 +41,7 @@ module ActiveRecord end def test_aliasing_types - string = String.new + string = +"" mapping = TypeMap.new mapping.register_type(/string/i, string) @@ -73,7 +73,7 @@ module ActiveRecord end def test_register_proc - string = String.new + string = +"" binary = Binary.new mapping = TypeMap.new diff --git a/activerecord/test/cases/unconnected_test.rb b/activerecord/test/cases/unconnected_test.rb index f4d8be5897..49746996bc 100644 --- a/activerecord/test/cases/unconnected_test.rb +++ b/activerecord/test/cases/unconnected_test.rb @@ -11,6 +11,12 @@ class TestUnconnectedAdapter < ActiveRecord::TestCase def setup @underlying = ActiveRecord::Base.connection @specification = ActiveRecord::Base.remove_connection + + # Clear out connection info from other pids (like a fork parent) too + pool_map = ActiveRecord::Base.connection_handler.instance_variable_get(:@owner_to_pool) + (pool_map.keys - [Process.pid]).each do |other_pid| + pool_map.delete(other_pid) + end end teardown do @@ -29,7 +35,15 @@ class TestUnconnectedAdapter < ActiveRecord::TestCase end end + def test_error_message_when_connection_not_established + error = assert_raise(ActiveRecord::ConnectionNotEstablished) do + TestRecord.find(1) + end + + assert_equal "No connection pool with 'primary' found.", error.message + 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/validations/absence_validation_test.rb b/activerecord/test/cases/validations/absence_validation_test.rb index 8235a54d8a..1982734f02 100644 --- a/activerecord/test/cases/validations/absence_validation_test.rb +++ b/activerecord/test/cases/validations/absence_validation_test.rb @@ -61,7 +61,7 @@ class AbsenceValidationTest < ActiveRecord::TestCase def test_validates_absence_of_virtual_attribute_on_model repair_validations(Interest) do - Interest.send(:attr_accessor, :token) + Interest.attr_accessor(:token) Interest.validates_absence_of(:token) interest = Interest.create!(topic: "Thought Leadering") diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb index 703c24b340..993c201f03 100644 --- a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb +++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb @@ -4,16 +4,20 @@ require "cases/helper" require "models/topic" class I18nGenerateMessageValidationTest < ActiveRecord::TestCase + class Backend < I18n::Backend::Simple + include I18n::Backend::Fallbacks + end + def setup Topic.clear_validators! @topic = Topic.new - I18n.backend = I18n::Backend::Simple.new + I18n.backend = Backend.new end def reset_i18n_load_path @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend I18n.load_path.clear - I18n.backend = I18n::Backend::Simple.new + I18n.backend = Backend.new yield ensure I18n.load_path.replace @old_load_path @@ -83,4 +87,16 @@ class I18nGenerateMessageValidationTest < ActiveRecord::TestCase assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title") end end + + test "activerecord attributes scope falls back to parent locale before it falls back to the :errors namespace" do + reset_i18n_load_path do + I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { attributes: { title: { taken: "custom en message" } } } } } } + I18n.backend.store_translations "en-US", errors: { messages: { taken: "generic en-US fallback" } } + + I18n.with_locale "en-US" do + assert_equal "custom en message", @topic.errors.generate_message(:title, :taken, value: "title") + assert_equal "generic en-US fallback", @topic.errors.generate_message(:heading, :taken, value: "heading") + end + end + end end diff --git a/activerecord/test/cases/validations/length_validation_test.rb b/activerecord/test/cases/validations/length_validation_test.rb index 62cd89041a..a7cb718043 100644 --- a/activerecord/test/cases/validations/length_validation_test.rb +++ b/activerecord/test/cases/validations/length_validation_test.rb @@ -17,7 +17,7 @@ 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_not o.save assert_predicate o.errors[:pets], :any? o.pets.build("name" => "apet") assert_predicate o, :valid? @@ -26,21 +26,21 @@ class LengthValidationTest < ActiveRecord::TestCase 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_not o.save assert_predicate o.errors[:pets], :any? o.pets.build("name" => "apet") assert_predicate o, :valid? 2.times { o.pets.build("name" => "apet") } - assert !o.save + 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_not o.save assert_predicate o.errors[:pets], :any? o.pets.build("name" => "あいうえおかきくけこ") assert_predicate o, :valid? @@ -64,7 +64,7 @@ class LengthValidationTest < ActiveRecord::TestCase def test_validates_length_of_virtual_attribute_on_model repair_validations(Pet) do - Pet.send(:attr_accessor, :nickname) + Pet.attr_accessor(:nickname) Pet.validates_length_of(:name, minimum: 1) Pet.validates_length_of(:nickname, minimum: 1) diff --git a/activerecord/test/cases/validations/presence_validation_test.rb b/activerecord/test/cases/validations/presence_validation_test.rb index 63c3f67da2..4b9cbe9098 100644 --- a/activerecord/test/cases/validations/presence_validation_test.rb +++ b/activerecord/test/cases/validations/presence_validation_test.rb @@ -69,7 +69,7 @@ class PresenceValidationTest < ActiveRecord::TestCase def test_validates_presence_of_virtual_attribute_on_model repair_validations(Interest) do - Interest.send(:attr_accessor, :abbreviation) + Interest.attr_accessor(:abbreviation) Interest.validates_presence_of(:topic) Interest.validates_presence_of(:abbreviation) diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb index 941aed5402..76163e3093 100644 --- a/activerecord/test/cases/validations/uniqueness_validation_test.rb +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -83,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" @@ -106,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 @@ -146,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" @@ -172,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 @@ -193,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 @@ -205,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 @@ -215,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 @@ -232,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 @@ -257,15 +257,15 @@ 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_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_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? @@ -283,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 @@ -314,6 +314,51 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert t3.save, "Should save t3 as unique" end + if current_adapter?(:Mysql2Adapter) + def test_deprecate_validate_uniqueness_mismatched_collation + Topic.validates_uniqueness_of(:author_email_address) + + topic1 = Topic.new(author_email_address: "david@loudthinking.com") + topic2 = Topic.new(author_email_address: "David@loudthinking.com") + + assert_equal 1, Topic.where(author_email_address: "david@loudthinking.com").count + + assert_deprecated do + assert_not topic1.valid? + assert_not topic1.save + assert topic2.valid? + assert topic2.save + end + + assert_equal 2, Topic.where(author_email_address: "david@loudthinking.com").count + assert_equal 2, Topic.where(author_email_address: "David@loudthinking.com").count + end + end + + def test_validate_case_sensitive_uniqueness_by_default + Topic.validates_uniqueness_of(:author_email_address) + + topic1 = Topic.new(author_email_address: "david@loudthinking.com") + topic2 = Topic.new(author_email_address: "David@loudthinking.com") + + assert_equal 1, Topic.where(author_email_address: "david@loudthinking.com").count + + ActiveSupport::Deprecation.silence do + assert_not topic1.valid? + assert_not topic1.save + assert topic2.valid? + assert topic2.save + end + + if current_adapter?(:Mysql2Adapter) + assert_equal 2, Topic.where(author_email_address: "david@loudthinking.com").count + assert_equal 2, Topic.where(author_email_address: "David@loudthinking.com").count + else + assert_equal 1, Topic.where(author_email_address: "david@loudthinking.com").count + assert_equal 1, Topic.where(author_email_address: "David@loudthinking.com").count + end + end + def test_validate_case_sensitive_uniqueness Topic.validates_uniqueness_of(:title, case_sensitive: true, allow_nil: true) @@ -349,7 +394,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase 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 @@ -417,12 +462,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" @@ -430,12 +475,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 @@ -446,7 +491,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" @@ -510,7 +555,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase abc.save! end assert_match(/\AUnknown primary key for table dashboards in model/, e.message) - assert_match(/Can not validate uniqueness for persisted record without primary key.\z/, e.message) + assert_match(/Cannot validate uniqueness for persisted record without primary key.\z/, e.message) end def test_validate_uniqueness_ignores_itself_when_primary_key_changed diff --git a/activerecord/test/cases/validations_test.rb b/activerecord/test/cases/validations_test.rb index 6c83bbd15c..9a70934b7e 100644 --- a/activerecord/test/cases/validations_test.rb +++ b/activerecord/test/cases/validations_test.rb @@ -3,11 +3,11 @@ 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 @@ -39,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" @@ -125,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 @@ -144,6 +144,13 @@ class ValidationsTest < ActiveRecord::TestCase assert_equal "100,000", d.salary_before_type_cast end + def test_validates_acceptance_of_with_undefined_attribute_methods + Topic.validates_acceptance_of(:approved) + topic = Topic.new(approved: true) + Topic.undefine_attribute_methods + assert topic.approved + end + def test_validates_acceptance_of_as_database_column Topic.validates_acceptance_of(:approved) topic = Topic.create("approved" => true) @@ -183,6 +190,22 @@ class ValidationsTest < ActiveRecord::TestCase 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 klass = Class.new(ActiveRecord::Base) do self.table_name = "posts" diff --git a/activerecord/test/cases/view_test.rb b/activerecord/test/cases/view_test.rb index 7e2d66c62a..36b9df7ba5 100644 --- a/activerecord/test/cases/view_test.rb +++ b/activerecord/test/cases/view_test.rb @@ -20,7 +20,7 @@ module ViewBehavior def setup super @connection = ActiveRecord::Base.connection - create_view "ebooks'", <<-SQL + create_view "ebooks'", <<~SQL SELECT id, name, status FROM books WHERE format = 'ebook' SQL end @@ -106,7 +106,7 @@ if ActiveRecord::Base.connection.supports_views? setup do @connection = ActiveRecord::Base.connection - @connection.execute <<-SQL + @connection.execute <<~SQL CREATE VIEW paperbacks AS SELECT name, status FROM books WHERE format = 'paperback' SQL @@ -156,8 +156,7 @@ if ActiveRecord::Base.connection.supports_views? end # sqlite dose not support CREATE, INSERT, and DELETE for VIEW - if current_adapter?(:Mysql2Adapter, :SQLServerAdapter) || - current_adapter?(:PostgreSQLAdapter) && ActiveRecord::Base.connection.postgresql_version >= 90300 + if current_adapter?(:Mysql2Adapter, :SQLServerAdapter, :PostgreSQLAdapter) class UpdateableViewTest < ActiveRecord::TestCase self.use_transactional_tests = false @@ -169,7 +168,7 @@ if ActiveRecord::Base.connection.supports_views? setup do @connection = ActiveRecord::Base.connection - @connection.execute <<-SQL + @connection.execute <<~SQL CREATE VIEW printed_books AS SELECT id, name, status, format FROM books WHERE format = 'paperback' SQL @@ -207,8 +206,7 @@ if ActiveRecord::Base.connection.supports_views? end # end of `if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :SQLServerAdapter)` end # end of `if ActiveRecord::Base.connection.supports_views?` -if ActiveRecord::Base.connection.respond_to?(:supports_materialized_views?) && - ActiveRecord::Base.connection.supports_materialized_views? +if ActiveRecord::Base.connection.supports_materialized_views? class MaterializedViewTest < ActiveRecord::PostgreSQLTestCase include ViewBehavior diff --git a/activerecord/test/config.example.yml b/activerecord/test/config.example.yml index 4bcb2aeea6..f5e3ac3c19 100644 --- a/activerecord/test/config.example.yml +++ b/activerecord/test/config.example.yml @@ -1,7 +1,5 @@ default_connection: <%= defined?(JRUBY_VERSION) ? 'jdbcsqlite3' : 'sqlite3' %> -with_manual_interventions: false - connections: jdbcderby: arunit: activerecord_unittest @@ -54,11 +52,18 @@ connections: mysql2: arunit: username: rails - encoding: utf8 - collation: utf8_unicode_ci + encoding: utf8mb4 + collation: utf8mb4_unicode_ci +<% if ENV['MYSQL_HOST'] %> + host: <%= ENV['MYSQL_HOST'] %> +<% end %> arunit2: username: rails - encoding: utf8 + encoding: utf8mb4 + collation: utf8mb4_general_ci +<% if ENV['MYSQL_HOST'] %> + host: <%= ENV['MYSQL_HOST'] %> +<% end %> oracle: arunit: diff --git a/activerecord/test/fixtures/citations.yml b/activerecord/test/fixtures/citations.yml new file mode 100644 index 0000000000..396099621c --- /dev/null +++ b/activerecord/test/fixtures/citations.yml @@ -0,0 +1,5 @@ +<% 65536.times do |i| %> +fixture_no_<%= i %>: + id: <%= i %> + book2_id: <%= i*i %> +<% end %> 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/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/models/account.rb b/activerecord/test/models/account.rb index 0c3cd45a81..639e395743 100644 --- a/activerecord/test/models/account.rb +++ b/activerecord/test/models/account.rb @@ -11,9 +11,8 @@ class Account < ActiveRecord::Base end # Test private kernel method through collection proxy using has_many. - def self.open - where("firm_name = ?", "37signals") - end + scope :open, -> { where("firm_name = ?", "37signals") } + scope :available, -> { open } before_destroy do |account| if account.firm @@ -32,3 +31,11 @@ class Account < ActiveRecord::Base "Sir, yes sir!" end end + +class SubAccount < Account + def self.instantiate_instance_of(klass, attributes, column_types = {}, &block) + klass = superclass + super + end + private_class_method :instantiate_instance_of +end diff --git a/activerecord/test/models/admin/user.rb b/activerecord/test/models/admin/user.rb index 3f55364510..691f9f11be 100644 --- a/activerecord/test/models/admin/user.rb +++ b/activerecord/test/models/admin/user.rb @@ -22,6 +22,9 @@ class Admin::User < ActiveRecord::Base 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/author.rb b/activerecord/test/models/author.rb index bd12cdf7ef..67be59a1fe 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -81,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 @@ -162,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 @@ -217,3 +220,12 @@ class AuthorFavorite < ActiveRecord::Base belongs_to :author belongs_to :favorite_author, class_name: "Author" end + +class AuthorFavoriteWithScope < ActiveRecord::Base + self.table_name = "author_favorites" + + default_scope { order(id: :asc) } + + belongs_to :author + belongs_to :favorite_author, class_name: "Author" +end diff --git a/activerecord/test/models/bird.rb b/activerecord/test/models/bird.rb index be08636ac6..20af7c6122 100644 --- a/activerecord/test/models/bird.rb +++ b/activerecord/test/models/bird.rb @@ -6,9 +6,19 @@ class Bird < ActiveRecord::Base accepts_nested_attributes_for :pirate + before_save do + # force materialize_transactions + self.class.connection.materialize_transactions + end + attr_accessor :cancel_save_from_callback before_save :cancel_save_callback_method, if: :cancel_save_from_callback def cancel_save_callback_method throw(:abort) end + + attr_accessor :total_count, :enable_count + after_initialize do + self.total_count = Bird.count if enable_count + end end diff --git a/activerecord/test/models/car.rb b/activerecord/test/models/car.rb index 3d6a7a96c2..8614926626 100644 --- a/activerecord/test/models/car.rb +++ b/activerecord/test/models/car.rb @@ -20,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/category.rb b/activerecord/test/models/category.rb index 2ccc00bed9..8c86879dc6 100644 --- a/activerecord/test/models/category.rb +++ b/activerecord/test/models/category.rb @@ -26,6 +26,7 @@ class Category < ActiveRecord::Base has_many :categorizations has_many :special_categorizations has_many :post_comments, through: :posts, source: :comments + has_many :ordered_post_comments, -> { order(id: :desc) }, through: :posts, source: :comments has_many :authors, through: :categorizations has_many :authors_with_select, -> { select "authors.*, categorizations.post_id" }, through: :categorizations, source: :author diff --git a/activerecord/test/models/citation.rb b/activerecord/test/models/citation.rb index 3d786f27eb..cee3d18173 100644 --- a/activerecord/test/models/citation.rb +++ b/activerecord/test/models/citation.rb @@ -2,4 +2,5 @@ class Citation < ActiveRecord::Base belongs_to :reference_of, class_name: "Book", foreign_key: :book2_id + has_many :citations end diff --git a/activerecord/test/models/club.rb b/activerecord/test/models/club.rb index 2006e05fcf..13e72e9c50 100644 --- a/activerecord/test/models/club.rb +++ b/activerecord/test/models/club.rb @@ -10,7 +10,7 @@ class Club < ActiveRecord::Base has_many :favourites, -> { where(memberships: { favourite: true }) }, through: :memberships, source: :member - scope :general, -> { left_joins(:category).where(categories: { name: "General" }) } + scope :general, -> { left_joins(:category).where(categories: { name: "General" }).unscope(:limit) } private diff --git a/activerecord/test/models/company.rb b/activerecord/test/models/company.rb index fc6488f729..a0f48d23f1 100644 --- a/activerecord/test/models/company.rb +++ b/activerecord/test/models/company.rb @@ -13,6 +13,8 @@ class Company < AbstractCompany has_many :contracts has_many :developers, through: :contracts + attribute :metadata, :json + scope :of_first_firm, lambda { joins(account: :firm). where("firms.id" => 1) @@ -122,6 +124,12 @@ class RestrictedWithErrorFirm < Company has_many :companies, -> { order("id") }, foreign_key: "client_of", dependent: :restrict_with_error end +class Agency < Firm + has_many :projects, foreign_key: :firm_id + + accepts_nested_attributes_for :projects +end + class Client < Company belongs_to :firm, foreign_key: "client_of" belongs_to :firm_with_basic_id, class_name: "Firm", foreign_key: "firm_id" @@ -145,6 +153,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 @@ -189,4 +212,12 @@ end class VerySpecialClient < SpecialClient end +class NewlyContractedCompany < Company + has_many :new_contracts, foreign_key: "company_id" + + before_save do + self.new_contracts << NewContract.new + end +end + require "models/account" diff --git a/activerecord/test/models/contract.rb b/activerecord/test/models/contract.rb index f273badd85..89719775c4 100644 --- a/activerecord/test/models/contract.rb +++ b/activerecord/test/models/contract.rb @@ -5,7 +5,9 @@ class Contract < ActiveRecord::Base belongs_to :developer, primary_key: :id belongs_to :firm, foreign_key: "company_id" - before_save :hi + attribute :metadata, :json + + before_save :hi, :update_metadata after_save :bye attr_accessor :hi_count, :bye_count @@ -19,4 +21,12 @@ class Contract < ActiveRecord::Base @bye_count ||= 0 @bye_count += 1 end + + def update_metadata + self.metadata = { company_id: company_id, developer_id: developer_id } + end +end + +class NewContract < Contract + validates :company_id, presence: true end diff --git a/activerecord/test/models/country.rb b/activerecord/test/models/country.rb index 0c84a40de2..4b4a276a98 100644 --- a/activerecord/test/models/country.rb +++ b/activerecord/test/models/country.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true class Country < ActiveRecord::Base - self.primary_key = :country_id - has_and_belongs_to_many :treaties end diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb index 8881c69368..c6574cf6e7 100644 --- a/activerecord/test/models/developer.rb +++ b/activerecord/test/models/developer.rb @@ -207,6 +207,7 @@ end class MultiplePoorDeveloperCalledJamis < ActiveRecord::Base self.table_name = "developers" + default_scope { } default_scope -> { where(name: "Jamis") } default_scope -> { where(salary: 50000) } end @@ -279,3 +280,17 @@ class DeveloperWithIncorrectlyOrderedHasManyThrough < ActiveRecord::Base has_many :companies, through: :contracts has_many :contracts, foreign_key: :developer_id end + +class DeveloperName < ActiveRecord::Type::String + def deserialize(value) + "Developer: #{value}" + end +end + +class AttributedDeveloper < ActiveRecord::Base + self.table_name = "developers" + + attribute :name, DeveloperName.new + + self.ignored_columns += ["name"] +end diff --git a/activerecord/test/models/drink_designer.rb b/activerecord/test/models/drink_designer.rb index eb6701b84e..8258408f35 100644 --- a/activerecord/test/models/drink_designer.rb +++ b/activerecord/test/models/drink_designer.rb @@ -4,5 +4,11 @@ class DrinkDesigner < ActiveRecord::Base has_one :chef, as: :employable end +class DrinkDesignerWithPolymorphicDependentNullifyChef < ActiveRecord::Base + self.table_name = "drink_designers" + + has_one :chef, as: :employable, dependent: :nullify +end + class MocktailDesigner < DrinkDesigner end diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index 4315ba1941..6e33ac0a6d 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -26,13 +26,14 @@ class Member < ActiveRecord::Base 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 87f7aab9a2..e121a849d0 100644 --- a/activerecord/test/models/member_detail.rb +++ b/activerecord/test/models/member_detail.rb @@ -5,6 +5,7 @@ class MemberDetail < ActiveRecord::Base 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/parrot.rb b/activerecord/test/models/parrot.rb index ba9ddb8c6a..3bb5316eca 100644 --- a/activerecord/test/models/parrot.rb +++ b/activerecord/test/models/parrot.rb @@ -20,6 +20,12 @@ class Parrot < ActiveRecord::Base def increment_updated_count self.updated_count += 1 end + + def self.delete_all(*) + connection.delete("DELETE FROM parrots_pirates") + connection.delete("DELETE FROM parrots_treasures") + super + end end class LiveParrot < Parrot diff --git a/activerecord/test/models/person.rb b/activerecord/test/models/person.rb index 5cba1e440e..c3d15a571a 100644 --- a/activerecord/test/models/person.rb +++ b/activerecord/test/models/person.rb @@ -62,6 +62,11 @@ class PersonWithDependentNullifyJobs < ActiveRecord::Base has_many :jobs, source: :job, through: :references, dependent: :nullify end +class PersonWithPolymorphicDependentNullifyComments < ActiveRecord::Base + self.table_name = "people" + has_many :comments, as: :author, dependent: :nullify +end + class LoosePerson < ActiveRecord::Base self.table_name = "people" self.abstract_class = true diff --git a/activerecord/test/models/pirate.rb b/activerecord/test/models/pirate.rb index c8617d1cfe..8733398697 100644 --- a/activerecord/test/models/pirate.rb +++ b/activerecord/test/models/pirate.rb @@ -17,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 @@ -92,3 +98,19 @@ class FamousPirate < ActiveRecord::Base has_many :famous_ships validates_presence_of :catchphrase, on: :conference end + +class SpacePirate < ActiveRecord::Base + self.table_name = "pirates" + + belongs_to :parrot + belongs_to :parrot_with_annotation, -> { annotate("that tells jokes") }, class_name: :Parrot, foreign_key: :parrot_id + has_and_belongs_to_many :parrots, foreign_key: :pirate_id + has_and_belongs_to_many :parrots_with_annotation, -> { annotate("that are very colorful") }, class_name: :Parrot, foreign_key: :pirate_id + has_one :ship, foreign_key: :pirate_id + has_one :ship_with_annotation, -> { annotate("that is a rocket") }, class_name: :Ship, foreign_key: :pirate_id + has_many :birds, foreign_key: :pirate_id + has_many :birds_with_annotation, -> { annotate("that are also parrots") }, class_name: :Bird, foreign_key: :pirate_id + has_many :treasures, as: :looter + has_many :treasure_estimates, through: :treasures, source: :price_estimates + has_many :treasure_estimates_with_annotation, -> { annotate("yarrr") }, through: :treasures, source: :price_estimates +end diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index 54eb5e6783..395b534c63 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -31,6 +31,7 @@ class Post < ActiveRecord::Base belongs_to :author_with_posts, -> { includes(:posts) }, class_name: "Author", foreign_key: :author_id belongs_to :author_with_address, -> { includes(:author_address) }, class_name: "Author", foreign_key: :author_id + belongs_to :author_with_select, -> { select(:id) }, class_name: "Author", foreign_key: :author_id def first_comment super.body @@ -77,6 +78,7 @@ class Post < ActiveRecord::Base has_many :comments_with_extend_2, extend: [NamedExtension, NamedExtension2], class_name: "Comment", foreign_key: "post_id" has_many :author_favorites, through: :author + has_many :author_favorites_with_scope, through: :author, class_name: "AuthorFavoriteWithScope", source: "author_favorites" has_many :author_categorizations, through: :author, source: :categorizations has_many :author_addresses, through: :author has_many :author_address_extra_with_address, @@ -201,6 +203,10 @@ end class SubAbstractStiPost < AbstractStiPost; end +class NullPost < Post + default_scope { none } +end + class FirstPost < ActiveRecord::Base self.inheritance_column = :disabled self.table_name = "posts" @@ -210,6 +216,12 @@ class FirstPost < ActiveRecord::Base has_one :comment, foreign_key: :post_id end +class PostWithDefaultSelect < ActiveRecord::Base + self.table_name = "posts" + + default_scope { select(:author_id) } +end + class TaggedPost < Post has_many :taggings, -> { rewhere(taggable_type: "TaggedPost") }, as: :taggable has_many :tags, through: :taggings @@ -253,6 +265,8 @@ class SpecialPostWithDefaultScope < ActiveRecord::Base self.inheritance_column = :disabled self.table_name = "posts" default_scope { where(id: [1, 5, 6]) } + scope :unscoped_all, -> { unscoped { all } } + scope :authorless, -> { unscoped { where(author_id: 0) } } end class PostThatLoadsCommentsInAnAfterSaveHook < ActiveRecord::Base @@ -296,8 +310,6 @@ end class FakeKlass extend ActiveRecord::Delegation::DelegateCache - inherited self - class << self def connection Post.connection @@ -323,10 +335,14 @@ class FakeKlass table[name] end - def enforce_raw_sql_whitelist(*args) + def disallow_raw_sql!(*args) # noop end + def columns_hash + { "name" => nil } + end + def arel_table Post.arel_table end @@ -334,5 +350,11 @@ class FakeKlass def predicate_builder Post.predicate_builder end + + def base_class? + true + end end + + inherited self end diff --git a/activerecord/test/models/price_estimate.rb b/activerecord/test/models/price_estimate.rb index f1f88d8d8d..669d0991f7 100644 --- a/activerecord/test/models/price_estimate.rb +++ b/activerecord/test/models/price_estimate.rb @@ -1,6 +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/rating.rb b/activerecord/test/models/rating.rb index cf06bc6931..49aa38285f 100644 --- a/activerecord/test/models/rating.rb +++ b/activerecord/test/models/rating.rb @@ -3,4 +3,5 @@ class Rating < ActiveRecord::Base belongs_to :comment has_many :taggings, as: :taggable + has_many :taggings_without_tag, -> { left_joins(:tag).where("tags.id": nil) }, as: :taggable, class_name: "Tagging" end diff --git a/activerecord/test/models/reference.rb b/activerecord/test/models/reference.rb index 2a7a1e3b77..82185040d6 100644 --- a/activerecord/test/models/reference.rb +++ b/activerecord/test/models/reference.rb @@ -4,6 +4,7 @@ class Reference < ActiveRecord::Base belongs_to :person belongs_to :job + has_many :ideal_jobs, class_name: "Job", foreign_key: :ideal_reference_id has_many :agents_posts_authors, through: :person class << self; attr_accessor :make_comments; end diff --git a/activerecord/test/models/reply.rb b/activerecord/test/models/reply.rb index bc829ec67f..b35623a344 100644 --- a/activerecord/test/models/reply.rb +++ b/activerecord/test/models/reply.rb @@ -4,8 +4,15 @@ 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" + + scope :ordered, -> { Reply.order(:id) } +end + +class SillyReply < Topic + belongs_to :reply, foreign_key: "parent_id", counter_cache: :replies_count end class UniqueReply < Reply @@ -14,6 +21,7 @@ class UniqueReply < Reply end class SillyUniqueReply < UniqueReply + validates :content, uniqueness: true end class WrongReply < Reply @@ -52,10 +60,6 @@ class WrongReply < Reply end end -class SillyReply < Reply - belongs_to :reply, foreign_key: "parent_id", counter_cache: :replies_count -end - module Web class Reply < Web::Topic belongs_to :topic, foreign_key: "parent_id", counter_cache: true, class_name: "Web::Topic" diff --git a/activerecord/test/models/section.rb b/activerecord/test/models/section.rb new file mode 100644 index 0000000000..f8b4cc7936 --- /dev/null +++ b/activerecord/test/models/section.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Section < ActiveRecord::Base + belongs_to :session, inverse_of: :sections, autosave: true + belongs_to :seminar, inverse_of: :sections, autosave: true +end diff --git a/activerecord/test/models/seminar.rb b/activerecord/test/models/seminar.rb new file mode 100644 index 0000000000..c18aa86433 --- /dev/null +++ b/activerecord/test/models/seminar.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Seminar < ActiveRecord::Base + has_many :sections, inverse_of: :seminar, autosave: true, dependent: :destroy + has_many :sessions, through: :sections +end diff --git a/activerecord/test/models/session.rb b/activerecord/test/models/session.rb new file mode 100644 index 0000000000..db66b5297e --- /dev/null +++ b/activerecord/test/models/session.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Session < ActiveRecord::Base + has_many :sections, inverse_of: :session, autosave: true, dependent: :destroy + has_many :seminars, through: :sections +end diff --git a/activerecord/test/models/subscription.rb b/activerecord/test/models/subscription.rb index d1d5d21621..f87315fcd1 100644 --- a/activerecord/test/models/subscription.rb +++ b/activerecord/test/models/subscription.rb @@ -3,4 +3,6 @@ class Subscription < ActiveRecord::Base belongs_to :subscriber, counter_cache: :books_count belongs_to :book + + validates_presence_of :subscriber_id, :book_id end diff --git a/activerecord/test/models/topic.rb b/activerecord/test/models/topic.rb index fa50eeb6a4..77101090f2 100644 --- a/activerecord/test/models/topic.rb +++ b/activerecord/test/models/topic.rb @@ -12,19 +12,11 @@ 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, -> {} do + scope :anonymous_extension, -> { } do def one 1 end @@ -81,11 +73,25 @@ class Topic < ActiveRecord::Base 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) end + def self.nested_scoping(scope) + scope.base + end + private def default_written_on @@ -93,11 +99,11 @@ class Topic < ActiveRecord::Base end def destroy_children - self.class.where("parent_id = #{id}").delete_all + self.class.delete_by(parent_id: id) end def set_email_address - unless persisted? + unless persisted? || will_save_change_to_author_email_address? self.author_email_address = "test@test.com" end end @@ -113,10 +119,6 @@ class Topic < ActiveRecord::Base end end -class ImportantTopic < Topic - serialize :important, Hash -end - class DefaultRejectedTopic < Topic default_scope -> { where(approved: false) } end @@ -128,6 +130,10 @@ class BlankTopic < Topic end end +class TitlePrimaryKeyTopic < Topic + self.primary_key = :title +end + module Web class Topic < ActiveRecord::Base has_many :replies, dependent: :destroy, foreign_key: "parent_id", class_name: "Web::Reply" diff --git a/activerecord/test/models/treaty.rb b/activerecord/test/models/treaty.rb index 5c1d75aa09..b87a757d2a 100644 --- a/activerecord/test/models/treaty.rb +++ b/activerecord/test/models/treaty.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true class Treaty < ActiveRecord::Base - self.primary_key = :treaty_id - has_and_belongs_to_many :countries end diff --git a/activerecord/test/models/wheel.rb b/activerecord/test/models/wheel.rb index 8db57d181e..22fc74995f 100644 --- a/activerecord/test/models/wheel.rb +++ b/activerecord/test/models/wheel.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Wheel < ActiveRecord::Base - belongs_to :wheelable, polymorphic: true, counter_cache: true, touch: true + belongs_to :wheelable, polymorphic: true, counter_cache: true, touch: :wheels_owned_at end diff --git a/activerecord/test/schema/mysql2_specific_schema.rb b/activerecord/test/schema/mysql2_specific_schema.rb index e634e9e6b1..b143035213 100644 --- a/activerecord/test/schema/mysql2_specific_schema.rb +++ b/activerecord/test/schema/mysql2_specific_schema.rb @@ -1,21 +1,33 @@ # frozen_string_literal: true ActiveRecord::Schema.define do - - if ActiveRecord::Base.connection.version >= "5.6.0" + 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" + if supports_default_expression? + t.binary :uuid, limit: 36, default: -> { "(uuid())" } + end end create_table :binary_fields, force: true do |t| t.binary :var_binary, limit: 255 t.binary :var_binary_large, limit: 4095 + t.tinyblob :tiny_blob t.blob :normal_blob t.mediumblob :medium_blob @@ -25,10 +37,17 @@ ActiveRecord::Schema.define do t.mediumtext :medium_text t.longtext :long_text + t.binary :tiny_blob_2, size: :tiny + t.binary :medium_blob_2, size: :medium + t.binary :long_blob_2, size: :long + t.text :tiny_text_2, size: :tiny + t.text :medium_text_2, size: :medium + t.text :long_text_2, size: :long + 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 @@ -38,38 +57,30 @@ ActiveRecord::Schema.define do end create_table :collation_tests, id: false, force: true do |t| - t.string :string_cs_column, limit: 1, collation: "utf8_bin" - t.string :string_ci_column, limit: 1, collation: "utf8_general_ci" + t.string :string_cs_column, limit: 1, collation: "utf8mb4_bin" + t.string :string_ci_column, limit: 1, collation: "utf8mb4_general_ci" t.binary :binary_column, limit: 1 end - ActiveRecord::Base.connection.execute <<-SQL -DROP PROCEDURE IF EXISTS ten; -SQL - - ActiveRecord::Base.connection.execute <<-SQL -CREATE PROCEDURE ten() SQL SECURITY INVOKER -BEGIN - select 10; -END -SQL + create_table :enum_tests, id: false, force: true do |t| + t.column :enum_column, "ENUM('text','blob','tiny','medium','long','unsigned','bigint')" + end - ActiveRecord::Base.connection.execute <<-SQL -DROP PROCEDURE IF EXISTS topics; -SQL + execute "DROP PROCEDURE IF EXISTS ten" - ActiveRecord::Base.connection.execute <<-SQL -CREATE PROCEDURE topics(IN num INT) SQL SECURITY INVOKER -BEGIN - select * from topics limit num; -END -SQL + execute <<~SQL + CREATE PROCEDURE ten() SQL SECURITY INVOKER + BEGIN + SELECT 10; + END + SQL - ActiveRecord::Base.connection.drop_table "enum_tests", if_exists: true + execute "DROP PROCEDURE IF EXISTS topics" - ActiveRecord::Base.connection.execute <<-SQL -CREATE TABLE enum_tests ( - enum_column ENUM('text','blob','tiny','medium','long','unsigned','bigint') -) -SQL + execute <<~SQL + CREATE PROCEDURE topics(IN num INT) SQL SECURITY INVOKER + BEGIN + SELECT * FROM topics LIMIT num; + END + SQL end diff --git a/activerecord/test/schema/oracle_specific_schema.rb b/activerecord/test/schema/oracle_specific_schema.rb index e236571caa..08c6e24555 100644 --- a/activerecord/test/schema/oracle_specific_schema.rb +++ b/activerecord/test/schema/oracle_specific_schema.rb @@ -1,30 +1,27 @@ # 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 execute "drop table defaults" rescue nil execute "drop sequence defaults_seq" rescue nil - execute <<-SQL -create table test_oracle_defaults ( - id integer not null primary key, - test_char char(1) default 'X' not null, - test_string varchar2(20) default 'hello' not null, - test_int integer default 3 not null -) + execute <<~SQL + create table test_oracle_defaults ( + id integer not null primary key, + test_char char(1) default 'X' not null, + test_string varchar2(20) default 'hello' not null, + test_int integer default 3 not null + ) SQL - execute <<-SQL -create sequence test_oracle_defaults_seq minvalue 10000 - SQL + execute "create sequence test_oracle_defaults_seq minvalue 10000" execute "create sequence companies_nonstd_seq minvalue 10000" - execute <<-SQL - CREATE TABLE defaults ( + execute <<~SQL + CREATE TABLE defaults ( id integer not null, modified_date date default sysdate, modified_date_function date default sysdate, @@ -35,8 +32,7 @@ create sequence test_oracle_defaults_seq minvalue 10000 char1 varchar2(1) default 'Y', char2 varchar2(50) default 'a varchar field', char3 clob default 'a text field' - ) + ) 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 f15178d695..975824ed51 100644 --- a/activerecord/test/schema/postgresql_specific_schema.rb +++ b/activerecord/test/schema/postgresql_specific_schema.rb @@ -1,7 +1,6 @@ # 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? diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 350113eaab..7d9b8afeb6 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -8,10 +8,18 @@ ActiveRecord::Schema.define do # # # ------------------------------------------------------------------- # + case_sensitive_options = + if current_adapter?(:Mysql2Adapter) + { collation: "utf8mb4_bin" } + else + {} + end + create_table :accounts, force: true do |t| t.references :firm, index: false t.string :firm_name t.integer :credit_limit + t.integer "a" * max_identifier_length end create_table :admin_accounts, force: true do |t| @@ -23,6 +31,7 @@ ActiveRecord::Schema.define do 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 @@ -35,6 +44,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| @@ -91,19 +101,24 @@ ActiveRecord::Schema.define do t.integer :pirate_id end - create_table :books, force: true do |t| + create_table :books, id: :integer, force: true do |t| + default_zero = { default: 0 } t.references :author t.string :format t.column :name, :string - t.column :status, :integer, default: 0 - t.column :read_status, :integer, default: 0 + t.column :status, :integer, **default_zero + t.column :read_status, :integer, **default_zero t.column :nullable_status, :integer - t.column :language, :integer, default: 0 - t.column :author_visibility, :integer, default: 0 - t.column :illustrator_visibility, :integer, default: 0 - t.column :font_size, :integer, default: 0 - t.column :difficulty, :integer, default: 0 + t.column :language, :integer, **default_zero + t.column :author_visibility, :integer, **default_zero + t.column :illustrator_visibility, :integer, **default_zero + t.column :font_size, :integer, **default_zero + t.column :difficulty, :integer, **default_zero t.column :cover, :string, default: "hard" + t.string :isbn + t.datetime :published_on + t.index [:author_id, :name], unique: true + t.index :isbn, where: "published_on IS NOT NULL", unique: true end create_table :booleans, force: true do |t| @@ -125,7 +140,8 @@ ActiveRecord::Schema.define do create_table :cars, force: true do |t| t.string :name t.integer :engines_count - t.integer :wheels_count, default: 0 + 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 @@ -155,8 +171,9 @@ ActiveRecord::Schema.define do end create_table :citations, force: true do |t| - t.column :book1_id, :integer - t.column :book2_id, :integer + t.references :book1 + t.references :book2 + t.references :citation end create_table :clubs, force: true do |t| @@ -212,7 +229,7 @@ ActiveRecord::Schema.define do 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 "(CASE WHEN rating > 0 THEN lower(name) END)", name: "company_expression_index" if supports_expression_index? + t.index "(CASE WHEN rating > 0 THEN lower(name) END) DESC", name: "company_expression_index" if supports_expression_index? end create_table :content, force: true do |t| @@ -243,6 +260,7 @@ ActiveRecord::Schema.define do create_table :contracts, force: true do |t| t.references :developer, index: false t.references :company, index: false + t.string :metadata end create_table :customers, force: true do |t| @@ -260,7 +278,7 @@ ActiveRecord::Schema.define do end create_table :dashboards, force: true, id: false do |t| - t.string :dashboard_id + t.string :dashboard_id, **case_sensitive_options t.string :name end @@ -324,7 +342,7 @@ ActiveRecord::Schema.define do end create_table :essays, force: true do |t| - t.string :name + t.string :name, **case_sensitive_options t.string :writer_id t.string :writer_type t.string :category_id @@ -332,7 +350,7 @@ ActiveRecord::Schema.define do end create_table :events, force: true do |t| - t.string :title, limit: 5 + t.string :title, limit: 5, **case_sensitive_options end create_table :eyes, force: true do |t| @@ -374,7 +392,7 @@ ActiveRecord::Schema.define do end create_table :guids, force: true do |t| - t.column :key, :string + t.column :key, :string, **case_sensitive_options end create_table :guitars, force: true do |t| @@ -382,8 +400,8 @@ ActiveRecord::Schema.define do end create_table :inept_wizards, force: true do |t| - t.column :name, :string, null: false - t.column :city, :string, null: false + t.column :name, :string, null: false, **case_sensitive_options + t.column :city, :string, null: false, **case_sensitive_options t.column :type, :string end @@ -486,7 +504,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| @@ -596,33 +615,55 @@ ActiveRecord::Schema.define do t.integer :non_poly_two_id end - create_table :parrots, force: true do |t| - t.column :name, :string - t.column :color, :string - t.column :parrot_sti_class, :string - t.column :killer_id, :integer - t.column :updated_count, :integer, default: 0 - if subsecond_precision_supported? - t.column :created_at, :datetime, precision: 0 - t.column :created_on, :datetime, precision: 0 - t.column :updated_at, :datetime, precision: 0 - t.column :updated_on, :datetime, precision: 0 - else - t.column :created_at, :datetime - t.column :created_on, :datetime - t.column :updated_at, :datetime - t.column :updated_on, :datetime + disable_referential_integrity do + create_table :parrots, force: :cascade do |t| + t.string :name + t.string :color + t.string :parrot_sti_class + t.integer :killer_id + t.integer :updated_count, :integer, default: 0 + if subsecond_precision_supported? + t.datetime :created_at, precision: 0 + t.datetime :created_on, precision: 0 + t.datetime :updated_at, precision: 0 + t.datetime :updated_on, precision: 0 + else + t.datetime :created_at + t.datetime :created_on + t.datetime :updated_at + t.datetime :updated_on + end end - end - create_table :parrots_pirates, id: false, force: true do |t| - t.column :parrot_id, :integer - t.column :pirate_id, :integer - end + create_table :pirates, force: :cascade do |t| + t.string :catchphrase + t.integer :parrot_id + t.integer :non_validated_parrot_id + if subsecond_precision_supported? + t.datetime :created_on, precision: 6 + t.datetime :updated_on, precision: 6 + else + t.datetime :created_on + t.datetime :updated_on + end + end - create_table :parrots_treasures, id: false, force: true do |t| - t.column :parrot_id, :integer - t.column :treasure_id, :integer + create_table :treasures, force: :cascade do |t| + t.string :name + t.string :type + t.references :looter, polymorphic: true + t.references :ship + end + + create_table :parrots_pirates, id: false, force: true do |t| + t.references :parrot, foreign_key: true + t.references :pirate, foreign_key: true + end + + create_table :parrots_treasures, id: false, force: true do |t| + t.references :parrot, foreign_key: true + t.references :treasure, foreign_key: true + end end create_table :people, force: true do |t| @@ -655,11 +696,7 @@ ActiveRecord::Schema.define do create_table :pets, primary_key: :pet_id, force: true do |t| t.string :name t.integer :owner_id, :integer - if subsecond_precision_supported? - t.timestamps null: false, precision: 6 - else - t.timestamps null: false - end + t.timestamps end create_table :pets_treasures, force: true do |t| @@ -668,19 +705,6 @@ ActiveRecord::Schema.define do t.column :rainbow_color, :string end - create_table :pirates, force: true do |t| - t.column :catchphrase, :string - t.column :parrot_id, :integer - t.integer :non_validated_parrot_id - if subsecond_precision_supported? - t.column :created_on, :datetime, precision: 6 - t.column :updated_on, :datetime, precision: 6 - else - t.column :created_on, :datetime - t.column :updated_on, :datetime - end - end - create_table :posts, force: true do |t| t.references :author t.string :title, null: false @@ -768,6 +792,24 @@ ActiveRecord::Schema.define do t.integer :lock_version, default: 0 end + disable_referential_integrity do + create_table :seminars, force: :cascade do |t| + t.string :name + end + + create_table :sessions, force: :cascade do |t| + t.date :start_date + t.date :end_date + t.string :name + end + + create_table :sections, force: :cascade do |t| + t.string :short_name + t.belongs_to :session, foreign_key: true + t.belongs_to :seminar, foreign_key: true + end + end + create_table :shape_expressions, force: true do |t| t.string :paint_type t.integer :paint_id @@ -864,8 +906,8 @@ ActiveRecord::Schema.define do end create_table :topics, force: true do |t| - t.string :title, limit: 250 - t.string :author_name + t.string :title, limit: 250, **case_sensitive_options + t.string :author_name, **case_sensitive_options t.string :author_email_address if subsecond_precision_supported? t.datetime :written_on, precision: 6 @@ -877,10 +919,10 @@ ActiveRecord::Schema.define do # use VARCHAR2(4000) instead of CLOB datatype as CLOB data type has many limitations in # Oracle SELECT WHERE clause which causes many unit test failures if current_adapter?(:OracleAdapter) - t.string :content, limit: 4000 + t.string :content, limit: 4000, **case_sensitive_options t.string :important, limit: 4000 else - t.text :content + t.text :content, **case_sensitive_options t.text :important end t.boolean :approved, default: true @@ -890,11 +932,7 @@ ActiveRecord::Schema.define do t.string :parent_title t.string :type t.string :group - if subsecond_precision_supported? - t.timestamps null: true, precision: 6 - else - t.timestamps null: true - end + t.timestamps null: true end create_table :toys, primary_key: :toy_id, force: true do |t| @@ -911,14 +949,6 @@ ActiveRecord::Schema.define do t.datetime :updated_at end - create_table :treasures, force: true do |t| - t.column :name, :string - t.column :type, :string - t.column :looter_id, :integer - t.column :looter_type, :string - t.belongs_to :ship - end - create_table :tuning_pegs, force: true do |t| t.integer :guitar_id t.float :pitch @@ -942,7 +972,7 @@ ActiveRecord::Schema.define do end [:circles, :squares, :triangles, :non_poly_ones, :non_poly_twos].each do |t| - create_table(t, force: true) {} + create_table(t, force: true) { } end create_table :men, force: true do |t| @@ -978,14 +1008,16 @@ ActiveRecord::Schema.define do t.references :wheelable, polymorphic: true end - create_table :countries, force: true, id: false, primary_key: "country_id" do |t| - t.string :country_id + create_table :countries, force: true, id: false do |t| + t.string :country_id, primary_key: true t.string :name end - create_table :treaties, force: true, id: false, primary_key: "treaty_id" do |t| - t.string :treaty_id + + create_table :treaties, force: true, id: false do |t| + t.string :treaty_id, primary_key: true t.string :name end + create_table :countries_treaties, force: true, primary_key: [:country_id, :treaty_id] do |t| t.string :country_id, null: false t.string :treaty_id, null: false 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 bd6d5c339b..de0d90a18f 100644 --- a/activerecord/test/support/config.rb +++ b/activerecord/test/support/config.rb @@ -13,34 +13,34 @@ module ARTest private - def config_file - Pathname.new(ENV["ARCONFIG"] || TEST_ROOT + "/config.yml") - end - - def read_config - unless config_file.exist? - FileUtils.cp TEST_ROOT + "/config.example.yml", config_file + def config_file + Pathname.new(ENV["ARCONFIG"] || TEST_ROOT + "/config.yml") end - erb = ERB.new(config_file.read) - expand_config(YAML.parse(erb.result(binding)).transform) - end + def read_config + unless config_file.exist? + FileUtils.cp TEST_ROOT + "/config.example.yml", config_file + end - def expand_config(config) - config["connections"].each do |adapter, connection| - dbs = [["arunit", "activerecord_unittest"], ["arunit2", "activerecord_unittest2"], - ["arunit_without_prepared_statements", "activerecord_unittest"]] - dbs.each do |name, dbname| - unless connection[name].is_a?(Hash) - connection[name] = { "database" => connection[name] } - end + erb = ERB.new(config_file.read) + expand_config(YAML.parse(erb.result(binding)).transform) + end - connection[name]["database"] ||= dbname - connection[name]["adapter"] ||= adapter + def expand_config(config) + config["connections"].each do |adapter, connection| + dbs = [["arunit", "activerecord_unittest"], ["arunit2", "activerecord_unittest2"], + ["arunit_without_prepared_statements", "activerecord_unittest"]] + dbs.each do |name, dbname| + unless connection[name].is_a?(Hash) + connection[name] = { "database" => connection[name] } + end + + connection[name]["database"] ||= dbname + connection[name]["adapter"] ||= adapter + end end - end - config - end + config + end end end diff --git a/activerecord/test/support/connection.rb b/activerecord/test/support/connection.rb index 2a4fa53460..367309dd85 100644 --- a/activerecord/test/support/connection.rb +++ b/activerecord/test/support/connection.rb @@ -21,6 +21,7 @@ module ARTest def self.connect puts "Using #{connection_name}" ActiveRecord::Base.logger = ActiveSupport::Logger.new("debug.log", 0, 100 * 1024 * 1024) + ActiveRecord::Base.connection_handlers = { ActiveRecord::Base.writing_role => ActiveRecord::Base.default_connection_handler } ActiveRecord::Base.configurations = connection_config ActiveRecord::Base.establish_connection :arunit ARUnit2Model.establish_connection :arunit2 diff --git a/activerecord/test/support/stubs/strong_parameters.rb b/activerecord/test/support/stubs/strong_parameters.rb new file mode 100644 index 0000000000..da8f9892f9 --- /dev/null +++ b/activerecord/test/support/stubs/strong_parameters.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/indifferent_access" + +class ProtectedParams + delegate :keys, :key?, :has_key?, :empty?, to: :@parameters + + def initialize(parameters = {}) + @parameters = parameters.with_indifferent_access + @permitted = false + end + + def permitted? + @permitted + end + + def permit! + @permitted = true + self + end + + def [](key) + @parameters[key] + end + + def to_h + @parameters.to_h + end + alias to_unsafe_h to_h + + def stringify_keys + dup + end + + def dup + super.tap do |duplicate| + duplicate.instance_variable_set :@permitted, @permitted + end + end +end |