diff options
Diffstat (limited to 'activerecord')
91 files changed, 1267 insertions, 296 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 148b3800a8..f0396c3d53 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,6 +1,45 @@ +* 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, there is a way to control the optimizer is by using 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): @@ -46,7 +85,7 @@ bulk deletes by `delete_all`. Supports skipping or upserting duplicates through the `ON CONFLICT` syntax - for Postgres (9.5+) and Sqlite (3.24+) and `ON DUPLICATE KEY UPDATE` syntax + for PostgreSQL (9.5+) and SQLite (3.24+) and `ON DUPLICATE KEY UPDATE` syntax for MySQL. *Bob Lail* @@ -319,7 +358,7 @@ *Gannon McGibbon* -* Cached columns_hash fields should be excluded from ResultSet#column_types +* 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: @@ -350,12 +389,12 @@ * Make the implicit order column configurable. - When calling ordered finder methods such as +first+ or +last+ without an + 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. + that `first` and `last` will return more predictable results. Example: @@ -425,7 +464,7 @@ *Sean Griffin* -* Add support for hash and url configs in database hash of `ActiveRecord::Base.connected_to`. +* Add support for hash and URL configs in database hash of `ActiveRecord::Base.connected_to`. ```` User.connected_to(database: { writing: "postgres://foo" }) do @@ -501,10 +540,10 @@ * Enum raises on invalid definition values - When defining a Hash enum it can be easy to use [] instead of {}. This + 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. + raises an `ArgumentError`. Fixes #33961 diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 7d66158f47..fd8d2edf28 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -55,7 +55,6 @@ module ActiveRecord autoload :Persistence autoload :QueryCache autoload :Querying - autoload :CollectionCacheKey autoload :ReadonlyAttributes autoload :RecordInvalid, "active_record/validations" autoload :Reflection diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 84a9797aa5..0d384950fe 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -57,21 +57,14 @@ module ActiveRecord @through_records[record.object_id] ||= begin ensure_mutable - through_record = through_association.build(*options_for_through_record) - through_record.send("#{source_reflection.name}=", record) + attributes = through_scope_attributes + attributes[source_reflection.name] = record + attributes[source_reflection.foreign_type] = options[:source_type] if options[:source_type] - if options[:source_type] - through_record.send("#{source_reflection.foreign_type}=", options[:source_type]) - end - - through_record + through_association.build(attributes) end end - def options_for_through_record - [through_scope_attributes] - end - def through_scope_attributes scope.where_values_hash(through_association.reflection.name.to_s). except!(through_association.reflection.foreign_key, diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 8997579527..c7cd87f9d4 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -143,9 +143,7 @@ module ActiveRecord def preloaders_for_reflection(reflection, records, scope) records.group_by { |record| record.association(reflection.name).klass }.map do |rhs_klass, rs| - loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) - loader.run self - loader + preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope).run end end @@ -166,10 +164,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 7048ff43b8..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 @@ -54,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? @@ -87,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 diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index 32653956b2..bec1c4c94a 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -4,45 +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() - 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, 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| - record.association(source_reflection.name).target + records = through_records.flat_map do |record| + source_records_by_owner[record] end - result.compact! - result.sort_by! { |rhs| preload_index[rhs] } if scope.order_values.any? - result.uniq! if scope.distinct_value - associate_records_to_owner(owner, result) - end - - unless scope.empty_scope? - middle_records.each do |owner| - owner.association(source_reflection.name).reset if owner - end + 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 @@ -52,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 @@ -61,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) @@ -92,7 +108,7 @@ module ActiveRecord end end - scope unless scope.empty_scope? + scope end end end diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index d77d76cb1e..fe94662543 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -457,10 +457,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 db097cb930..2af6d09b53 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -288,7 +288,6 @@ module ActiveRecord #:nodoc: extend Explain extend Enum extend Delegation::DelegateCache - extend CollectionCacheKey extend Aggregations::ClassMethods include Core diff --git a/activerecord/lib/active_record/collection_cache_key.rb b/activerecord/lib/active_record/collection_cache_key.rb deleted file mode 100644 index 4b6db8a96c..0000000000 --- a/activerecord/lib/active_record/collection_cache_key.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module CollectionCacheKey - def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: - query_signature = ActiveSupport::Digest.hexdigest(collection.to_sql) - key = "#{collection.model_name.cache_key}/query-#{query_signature}" - - if collection.loaded? || collection.distinct_value - size = collection.records.size - if size > 0 - timestamp = collection.max_by(×tamp_column)._read_attribute(timestamp_column) - end - else - if collection.eager_loading? - collection = collection.send(:apply_join_dependency) - end - column_type = type_for_attribute(timestamp_column) - column = connection.visitor.compile(collection.arel_attribute(timestamp_column)) - select_values = "COUNT(*) AS #{connection.quote_column_name("size")}, MAX(%s) AS timestamp" - - if collection.has_limit_or_offset? - query = collection.select("#{column} AS collection_cache_key_timestamp") - subquery_alias = "subquery_for_cache_key" - subquery_column = "#{subquery_alias}.collection_cache_key_timestamp" - subquery = query.arel.as(subquery_alias) - arel = Arel::SelectManager.new(subquery).project(select_values % subquery_column) - else - query = collection.unscope(:order) - query.select_values = [select_values % column] - arel = query.arel - end - - result = connection.select_one(arel, nil) - - if result.blank? - size = 0 - timestamp = nil - else - size = result["size"] - timestamp = column_type.deserialize(result["timestamp"]) - end - - end - - if timestamp - "#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" - else - "#{key}-#{size}" - end - end - end -end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 0ded1a5318..68498b5dc5 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -810,6 +810,7 @@ module ActiveRecord def new_connection Base.send(spec.adapter_method, spec.config).tap do |conn| conn.schema_cache = schema_cache.dup if schema_cache + conn.check_version end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 93b1c4e632..a7753e3e9c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -7,7 +7,8 @@ module ActiveRecord module QueryCache class << self def included(base) #:nodoc: - dirties_query_cache base, :insert, :update, :delete, :rollback_to_savepoint, :rollback_db_transaction + dirties_query_cache base, :insert, :update, :delete, :truncate, :truncate_tables, + :rollback_to_savepoint, :rollback_db_transaction base.set_callback :checkout, :after, :configure_query_cache! base.set_callback :checkin, :after, :disable_query_cache! diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index 6981ea6ecd..9f0335b31d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -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? diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 7aad306d50..200184c2f9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -133,8 +133,6 @@ module ActiveRecord @advisory_locks_enabled = self.class.type_cast_config_to_boolean( config.fetch(:advisory_locks, true) ) - - check_version end def replica? @@ -575,9 +573,17 @@ module ActiveRecord "INSERT #{insert.into} #{insert.values_list}" end + def get_database_version # :nodoc: + end + + def database_version # :nodoc: + schema_cache.database_version + end + + def check_version # :nodoc: + end + private - def check_version - end def type_map @type_map ||= Type::TypeMap.new.tap do |mapping| diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index 8ca2cfa9ed..f5da19f0f6 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -55,8 +55,8 @@ module ActiveRecord super(connection, logger, config) end - def version #:nodoc: - @version ||= Version.new(version_string) + def get_database_version #:nodoc: + Version.new(version_string) end def mariadb? # :nodoc: @@ -68,11 +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? && version >= "8.0.13" + !mariadb? && database_version >= "8.0.13" end def supports_transaction_isolation? @@ -96,16 +96,16 @@ module ActiveRecord end def supports_datetime_with_precision? - mariadb? || version >= "5.6.4" + mariadb? || database_version >= "5.6.4" end def supports_virtual_columns? - mariadb? || version >= "5.7.5" + mariadb? || database_version >= "5.7.5" end # See https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html for more details. def supports_optimizer_hints? - !mariadb? && version >= "5.7.7" + !mariadb? && database_version >= "5.7.7" end def supports_advisory_locks? @@ -526,12 +526,13 @@ module ActiveRecord sql end - private - def check_version - if version < "5.5.8" - raise "Your version of MySQL (#{version_string}) is too old. Active Record supports MySQL >= 5.5.8." - end + def check_version # :nodoc: + if database_version < "5.5.8" + raise "Your version of MySQL (#{database_version}) is too old. Active Record supports MySQL >= 5.5.8." end + end + + private def initialize_type_map(m = type_map) super @@ -702,7 +703,7 @@ module ActiveRecord end def supports_rename_index? - mariadb? ? false : version >= "5.7.6" + mariadb? ? false : database_version >= "5.7.6" end def configure_connection diff --git a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb index 1199c0ad1b..a14394fe04 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -145,15 +145,12 @@ module ActiveRecord elsif previous_packet.nil? true else - (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet + (current_packet.bytesize + previous_packet.bytesize + 2) > max_allowed_packet end end def max_allowed_packet - @max_allowed_packet ||= begin - bytes_margin = 2 - show_variable("max_allowed_packet") - bytes_margin - end + @max_allowed_packet ||= show_variable("max_allowed_packet") end def exec_stmt_and_free(sql, name, binds, cache_stmt: false) diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb index 57518b02fa..a37557361a 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb @@ -64,7 +64,7 @@ module ActiveRecord end def extract_expression_for_virtual_column(column) - if @connection.mariadb? && @connection.version < "10.2.5" + if @connection.mariadb? && @connection.database_version < "10.2.5" create_table_info = @connection.send(:create_table_info, column.table_name) column_name = @connection.quote_column_name(column.name) if %r/#{column_name} #{Regexp.quote(column.sql_type)}(?: COLLATE \w+)? AS \((?<expression>.+?)\) #{column.extra}/ =~ create_table_info diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb index 4018f0815c..a1f90a0642 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -126,9 +126,9 @@ module ActiveRecord def row_format_dynamic_by_default? if mariadb? - version >= "10.2.2" + database_version >= "10.2.2" else - version >= "5.7.9" + database_version >= "5.7.9" end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 9bdaa00336..0dc880c731 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -43,7 +43,7 @@ module ActiveRecord end def supports_json? - !mariadb? && version >= "5.7.8" + !mariadb? && database_version >= "5.7.8" end def supports_comments? diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb index a38c1325c0..41d1a8e4ab 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -287,7 +287,7 @@ module ActiveRecord quoted_sequence = quote_table_name(sequence) max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA") if max_pk.nil? - if postgresql_version >= 100000 + if database_version >= 100000 minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA") else minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA") diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 29f764e8f4..0ed4e61d18 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -201,7 +201,7 @@ module ActiveRecord end def supports_insert_on_conflict? - postgresql_version >= 90500 + database_version >= 90500 end alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? alias supports_insert_on_duplicate_update? supports_insert_on_conflict? @@ -344,7 +344,7 @@ module ActiveRecord end def supports_pgcrypto_uuid? - postgresql_version >= 90400 + database_version >= 90400 end def supports_optimizer_hints? @@ -424,7 +424,7 @@ module ActiveRecord } # Returns the version of the connected PostgreSQL server. - def postgresql_version + def get_database_version @connection.server_version end @@ -446,12 +446,13 @@ module ActiveRecord sql end - private - def check_version - if postgresql_version < 90300 - raise "Your version of PostgreSQL (#{postgresql_version}) is too old. Active Record supports PostgreSQL >= 9.3." - end + def check_version # :nodoc: + if database_version < 90300 + raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3." end + end + + private # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index 07453b4403..dbfe1e4a34 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -26,21 +26,23 @@ module ActiveRecord end def encode_with(coder) - coder["columns"] = @columns - coder["columns_hash"] = @columns_hash - coder["primary_keys"] = @primary_keys - coder["data_sources"] = @data_sources - coder["indexes"] = @indexes - coder["version"] = connection.migration_context.current_version + coder["columns"] = @columns + coder["columns_hash"] = @columns_hash + coder["primary_keys"] = @primary_keys + coder["data_sources"] = @data_sources + coder["indexes"] = @indexes + coder["version"] = connection.migration_context.current_version + coder["database_version"] = database_version end def init_with(coder) - @columns = coder["columns"] - @columns_hash = coder["columns_hash"] - @primary_keys = coder["primary_keys"] - @data_sources = coder["data_sources"] - @indexes = coder["indexes"] || {} - @version = coder["version"] + @columns = coder["columns"] + @columns_hash = coder["columns_hash"] + @primary_keys = coder["primary_keys"] + @data_sources = coder["data_sources"] + @indexes = coder["indexes"] || {} + @version = coder["version"] + @database_version = coder["database_version"] end def primary_keys(table_name) @@ -91,6 +93,10 @@ module ActiveRecord @indexes[table_name] ||= connection.indexes(table_name) end + def database_version # :nodoc: + @database_version ||= connection.get_database_version + end + # Clears out internal caches def clear! @columns.clear @@ -99,6 +105,7 @@ module ActiveRecord @data_sources.clear @indexes.clear @version = nil + @database_version = nil end def size @@ -117,11 +124,11 @@ module ActiveRecord def marshal_dump # if we get current version during initialization, it happens stack over flow. @version = connection.migration_context.current_version - [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes] + [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, database_version] end def marshal_load(array) - @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes = array + @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, @database_version = array @indexes = @indexes || {} end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb index 84dcae49b9..ffa75172b5 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -14,7 +14,7 @@ module ActiveRecord log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.execute_batch(sql) + @connection.execute_batch2(sql) end end end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index ff23a525b9..1801924c09 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -10,7 +10,7 @@ require "active_record/connection_adapters/sqlite3/schema_definitions" require "active_record/connection_adapters/sqlite3/schema_dumper" require "active_record/connection_adapters/sqlite3/schema_statements" -gem "sqlite3", "~> 1.3", ">= 1.3.6" +gem "sqlite3", "~> 1.4" require "sqlite3" module ActiveRecord @@ -111,7 +111,7 @@ module ActiveRecord end def supports_expression_index? - sqlite_version >= "3.9.0" + database_version >= "3.9.0" end def requires_reloading? @@ -135,7 +135,7 @@ module ActiveRecord end def supports_insert_on_conflict? - sqlite_version >= "3.24.0" + database_version >= "3.24.0" end alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? alias supports_insert_on_duplicate_update? supports_insert_on_conflict? @@ -397,6 +397,16 @@ module ActiveRecord sql end + def get_database_version # :nodoc: + SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) + end + + def check_version # :nodoc: + if database_version < "3.8.0" + raise "Your version of SQLite (#{database_version}) is too old. Active Record supports SQLite >= 3.8." + end + end + private # See https://www.sqlite.org/limits.html, # the default value is 999 when not configured. @@ -404,12 +414,6 @@ module ActiveRecord 999 end - def check_version - if sqlite_version < "3.8.0" - raise "Your version of SQLite (#{sqlite_version}) is too old. Active Record supports SQLite >= 3.8." - end - end - def initialize_type_map(m = type_map) super register_class_with_limit m, %r(int)i, SQLite3Integer @@ -527,10 +531,6 @@ module ActiveRecord SELECT #{quoted_from_columns} FROM #{quote_table_name(from)}") end - def sqlite_version - @sqlite_version ||= SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) - end - def translate_exception(exception, message:, sql:, binds:) case exception.message # SQLite 3.8.2 returns a newly formatted error message: diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index 7431a1c759..44b5cfc738 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -7,7 +7,7 @@ require "active_record/database_configurations/url_config" module ActiveRecord # ActiveRecord::DatabaseConfigurations returns an array of DatabaseConfig # objects (either a HashConfig or UrlConfig) that are constructed from the - # application's database configuration hash or url string. + # application's database configuration hash or URL string. class DatabaseConfigurations attr_reader :configurations delegate :any?, to: :configurations diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb index d30aee7c00..4b02d40aa0 100644 --- a/activerecord/lib/active_record/insert_all.rb +++ b/activerecord/lib/active_record/insert_all.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActiveRecord - class InsertAll + class InsertAll # :nodoc: attr_reader :model, :connection, :inserts, :keys attr_reader :on_duplicate, :returning, :unique_by @@ -73,15 +73,11 @@ module ActiveRecord raise ArgumentError, "#{connection.class} does not support :returning" end - unless %i{ raise skip update }.member?(on_duplicate) - raise NotImplementedError, "#{on_duplicate.inspect} is an unknown value for :on_duplicate. Valid values are :raise, :skip, and :update" - end - - if on_duplicate == :skip && !connection.supports_insert_on_duplicate_skip? + if skip_duplicates? && !connection.supports_insert_on_duplicate_skip? raise ArgumentError, "#{connection.class} does not support skipping duplicates" end - if on_duplicate == :update && !connection.supports_insert_on_duplicate_update? + if update_duplicates? && !connection.supports_insert_on_duplicate_update? raise ArgumentError, "#{connection.class} does not support upsert" end @@ -137,16 +133,16 @@ module ActiveRecord end def returning - quote_columns(insert_all.returning).join(",") if insert_all.returning + format_columns(insert_all.returning) if insert_all.returning end def conflict_target if index = insert_all.unique_by - sql = +"(#{quote_columns(index.columns).join(',')})" + sql = +"(#{format_columns(index.columns)})" sql << " WHERE #{index.where}" if index.where sql elsif update_duplicates? - "(#{quote_columns(insert_all.primary_keys).join(',')})" + "(#{format_columns(insert_all.primary_keys)})" end end @@ -158,7 +154,7 @@ module ActiveRecord attr_reader :connection, :insert_all def columns_list - quote_columns(insert_all.keys).join(",") + format_columns(insert_all.keys) end def extract_types_from_columns_on(table_name, keys:) @@ -170,6 +166,10 @@ module ActiveRecord keys.map { |key| [ key, connection.lookup_cast_type_from_column(columns[key]) ] }.to_h end + def format_columns(columns) + quote_columns(columns).join(",") + end + def quote_columns(columns) columns.map(&connection.method(:quote_column_name)) end diff --git a/activerecord/lib/active_record/integration.rb b/activerecord/lib/active_record/integration.rb index fa6f0d36ec..b769541e95 100644 --- a/activerecord/lib/active_record/integration.rb +++ b/activerecord/lib/active_record/integration.rb @@ -152,6 +152,10 @@ 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 @@ -180,7 +184,7 @@ module ActiveRecord # raw_timestamp_to_cache_version(timestamp) # # => "20181015200215266505" # - # Postgres truncates trailing zeros, + # PostgreSQL truncates trailing zeros, # https://github.com/postgres/postgres/commit/3e1beda2cde3495f41290e1ece5d544525810214 # to account for this we pad the output with zeros def raw_timestamp_to_cache_version(timestamp) diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index 88b0c828ae..e6166581f1 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -17,7 +17,7 @@ module ActiveRecord end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{internal_metadata_table_name}#{table_name_suffix}" end def []=(key, value) @@ -44,6 +44,10 @@ module ActiveRecord end end end + + def drop_table + connection.drop_table table_name, if_exists: true + end end end end diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index ba03a3773a..bf2f61737b 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -85,14 +85,14 @@ module ActiveRecord # ==== Options # # [:returning] - # (Postgres-only) An array of attributes to return for all successfully + # (PostgreSQL only) An array of attributes to return for all successfully # inserted records, which by default is the primary key. # Pass <tt>returning: %w[ id name ]</tt> for both id and name - # or <tt>returning: false</tt> to omit the underlying RETURNING SQL + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL # clause entirely. # # [:unique_by] - # (Postgres and SQLite only) By default rows are considered to be unique + # (PostgreSQL and SQLite only) By default rows are considered to be unique # by every unique index on the table. Any duplicate rows are skipped. # # To skip rows according to just one unique index pass <tt>:unique_by</tt>. @@ -107,9 +107,9 @@ module ActiveRecord # unique_by: %i[ author_id name ] # unique_by: :index_books_on_isbn # - # Because it relies on the index information from the database - # <tt>:unique_by</tt> is recommended to be paired with - # Active Record's schema_cache. + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. # # ==== Example # @@ -154,10 +154,10 @@ module ActiveRecord # ==== Options # # [:returning] - # (Postgres-only) An array of attributes to return for all successfully + # (PostgreSQL only) An array of attributes to return for all successfully # inserted records, which by default is the primary key. # Pass <tt>returning: %w[ id name ]</tt> for both id and name - # or <tt>returning: false</tt> to omit the underlying RETURNING SQL + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL # clause entirely. # # ==== Examples @@ -202,14 +202,14 @@ module ActiveRecord # ==== Options # # [:returning] - # (Postgres-only) An array of attributes to return for all successfully + # (PostgreSQL only) An array of attributes to return for all successfully # inserted records, which by default is the primary key. # Pass <tt>returning: %w[ id name ]</tt> for both id and name - # or <tt>returning: false</tt> to omit the underlying RETURNING SQL + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL # clause entirely. # # [:unique_by] - # (Postgres and SQLite only) By default rows are considered to be unique + # (PostgreSQL and SQLite only) By default rows are considered to be unique # by every unique index on the table. Any duplicate rows are skipped. # # To skip rows according to just one unique index pass <tt>:unique_by</tt>. @@ -224,9 +224,9 @@ module ActiveRecord # unique_by: %i[ author_id name ] # unique_by: :index_books_on_isbn # - # Because it relies on the index information from the database - # <tt>:unique_by</tt> is recommended to be paired with - # Active Record's schema_cache. + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. # # ==== Examples # @@ -238,7 +238,7 @@ module ActiveRecord # { title: "Eloquent Ruby", author: "Russ", isbn: "1" } # ], unique_by: :isbn) # - # Book.find_by(isbn: "1").title # => "Eloquent Ruby" + # Book.find_by(isbn: "1").title # => "Eloquent Ruby" def upsert_all(attributes, returning: nil, unique_by: nil) InsertAll.new(self, attributes, on_duplicate: :update, returning: returning, unique_by: unique_by).execute end @@ -568,7 +568,6 @@ module ActiveRecord became.send(:initialize) became.instance_variable_set("@attributes", @attributes) became.instance_variable_set("@mutations_from_database", @mutations_from_database ||= nil) - became.instance_variable_set("@changed_attributes", attributes_changed_by_setter) became.instance_variable_set("@new_record", new_record?) became.instance_variable_set("@destroyed", destroyed?) became.errors.copy!(errors) diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 86021f0a80..ae1501f5a1 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -13,9 +13,9 @@ module ActiveRecord :destroy_all, :delete_all, :update_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, :eager_load, :includes, :from, :lock, :readonly, :extending, :or, + :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, + :count, :average, :minimum, :maximum, :sum, :calculate, :annotate, :pluck, :pick, :ids ].freeze # :nodoc: delegate(*QUERYING_METHODS, to: :all) diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index f021a8f6c4..447def8d77 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -222,6 +222,16 @@ db_namespace = namespace :db do desc "Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)" task setup: ["db:schema:load_if_ruby", "db:structure:load_if_sql", :seed] + desc "Runs setup if database does not exist, or runs migrations if it does" + task prepare: :load_config do + ActiveRecord::Base.configurations.configs_for(env_name: 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: :load_config do db_namespace["abort_if_pending_migrations"].invoke diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 6d954a2b2e..c1ef98c063 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, :optimizer_hints] + :extending, :unscope, :optimizer_hints, :annotate] SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :skip_query_cache] @@ -317,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 diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index 4f9ddf302e..29b1dc97b7 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -129,11 +129,12 @@ module ActiveRecord relation = apply_join_dependency if operation.to_s.downcase == "count" - relation.distinct! - # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT - if (column_name == :all || column_name.nil?) && select_values.empty? - relation.order_values = [] + unless distinct_value || distinct_select?(column_name || select_for_count) + relation.distinct! + relation.select_values = [ klass.primary_key || table[Arel.star] ] end + # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT + relation.order_values = [] end relation.calculate(operation, column_name) @@ -416,16 +417,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/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 05ea0850d3..5f728f2263 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. @@ -349,7 +368,7 @@ module ActiveRecord end VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock, - :limit, :offset, :joins, :left_outer_joins, + :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. @@ -948,11 +967,40 @@ 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 + 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) @@ -1004,6 +1052,7 @@ module ActiveRecord 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 diff --git a/activerecord/lib/active_record/schema_migration.rb b/activerecord/lib/active_record/schema_migration.rb index 1fca1a18f6..74547de862 100644 --- a/activerecord/lib/active_record/schema_migration.rb +++ b/activerecord/lib/active_record/schema_migration.rb @@ -19,7 +19,7 @@ module ActiveRecord end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.schema_migrations_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{schema_migrations_table_name}#{table_name_suffix}" end def table_exists? diff --git a/activerecord/lib/active_record/store.rb b/activerecord/lib/active_record/store.rb index 3537e2d008..6fecb06897 100644 --- a/activerecord/lib/active_record/store.rb +++ b/activerecord/lib/active_record/store.rb @@ -11,6 +11,12 @@ module ActiveRecord # of the model. This is very helpful for easily exposing store keys to a form or elsewhere that's # already built around just accessing attributes on the model. # + # Every accessor comes with dirty tracking methods (+key_changed?+, +key_was+ and +key_change+) and + # methods to access the changes made during the last save (+saved_change_to_key?+, +saved_change_to_key+ and + # +key_before_last_save+). + # + # NOTE: There is no +key_will_change!+ method for accessors, use +store_will_change!+ instead. + # # Make sure that you declare the database column used for the serialized store as a text, so there's # plenty of room. # @@ -49,6 +55,12 @@ module ActiveRecord # u.settings[:country] # => 'Denmark' # u.settings['country'] # => 'Denmark' # + # # Dirty tracking + # u.color = 'green' + # u.color_changed? # => true + # u.color_was # => 'black' + # u.color_change # => ['black', 'red'] + # # # Add additional accessors to an existing store through store_accessor # class SuperUser < User # store_accessor :settings, :privileges, :servants @@ -127,6 +139,42 @@ module ActiveRecord define_method(accessor_key) do read_store_attribute(store_attribute, key) end + + define_method("#{accessor_key}_changed?") do + return false unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("#{accessor_key}_change") do + return unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_was") do + return unless attribute_changed?(store_attribute) + prev_store, _new_store = changes[store_attribute] + prev_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}?") do + return false unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_before_last_save") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, _new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) + end end end diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 155d2b0b98..7285c15477 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -186,8 +186,8 @@ module ActiveRecord ActiveRecord::Base.connected_to(database: { truncation: configuration }) do table_names = ActiveRecord::Base.connection.tables table_names -= [ - ActiveRecord::Base.schema_migrations_table_name, - ActiveRecord::Base.internal_metadata_table_name + SchemaMigration.table_name, + InternalMetadata.table_name ] ActiveRecord::Base.connection.truncate_tables(*table_names) diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index fe3842b905..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) diff --git a/activerecord/lib/arel/nodes.rb b/activerecord/lib/arel/nodes.rb index 2f6dd9bc45..f994754620 100644 --- a/activerecord/lib/arel/nodes.rb +++ b/activerecord/lib/arel/nodes.rb @@ -61,6 +61,8 @@ require "arel/nodes/outer_join" require "arel/nodes/right_outer_join" require "arel/nodes/string_join" +require "arel/nodes/comment" + require "arel/nodes/sql_literal" require "arel/nodes/casted" diff --git a/activerecord/lib/arel/nodes/comment.rb b/activerecord/lib/arel/nodes/comment.rb new file mode 100644 index 0000000000..237ff27e7e --- /dev/null +++ b/activerecord/lib/arel/nodes/comment.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Arel # :nodoc: all + module Nodes + class Comment < Arel::Nodes::Node + attr_reader :values + + def initialize(values) + super() + @values = values + end + + def initialize_copy(other) + super + @values = @values.clone + end + + def hash + [@values].hash + end + + def eql?(other) + self.class == other.class && + self.values == other.values + end + alias :== :eql? + end + end +end diff --git a/activerecord/lib/arel/nodes/select_core.rb b/activerecord/lib/arel/nodes/select_core.rb index 5df6ac8412..11b4f39ece 100644 --- a/activerecord/lib/arel/nodes/select_core.rb +++ b/activerecord/lib/arel/nodes/select_core.rb @@ -3,7 +3,7 @@ module Arel # :nodoc: all module Nodes class SelectCore < Arel::Nodes::Node - attr_accessor :projections, :wheres, :groups, :windows + attr_accessor :projections, :wheres, :groups, :windows, :comment attr_accessor :havings, :source, :set_quantifier, :optimizer_hints def initialize @@ -18,6 +18,7 @@ module Arel # :nodoc: all @groups = [] @havings = [] @windows = [] + @comment = nil end def from @@ -44,7 +45,7 @@ module Arel # :nodoc: all def hash [ @source, @set_quantifier, @projections, @optimizer_hints, - @wheres, @groups, @havings, @windows + @wheres, @groups, @havings, @windows, @comment ].hash end @@ -57,7 +58,8 @@ module Arel # :nodoc: all self.wheres == other.wheres && self.groups == other.groups && self.havings == other.havings && - self.windows == other.windows + self.windows == other.windows && + self.comment == other.comment end alias :== :eql? end diff --git a/activerecord/lib/arel/select_manager.rb b/activerecord/lib/arel/select_manager.rb index 32286b67f4..ddc9e394dd 100644 --- a/activerecord/lib/arel/select_manager.rb +++ b/activerecord/lib/arel/select_manager.rb @@ -244,6 +244,11 @@ module Arel # :nodoc: all @ctx.source end + def comment(*values) + @ctx.comment = Nodes::Comment.new(values) + self + end + private def collapse(exprs) exprs = exprs.compact diff --git a/activerecord/lib/arel/visitors/depth_first.rb b/activerecord/lib/arel/visitors/depth_first.rb index 109afb7402..d696edc507 100644 --- a/activerecord/lib/arel/visitors/depth_first.rb +++ b/activerecord/lib/arel/visitors/depth_first.rb @@ -181,6 +181,10 @@ module Arel # :nodoc: all visit o.limit end + def visit_Arel_Nodes_Comment(o) + visit o.values + end + def visit_Array(o) o.each { |i| visit i } end diff --git a/activerecord/lib/arel/visitors/dot.rb b/activerecord/lib/arel/visitors/dot.rb index 37803ce0c0..ecc386de07 100644 --- a/activerecord/lib/arel/visitors/dot.rb +++ b/activerecord/lib/arel/visitors/dot.rb @@ -234,6 +234,10 @@ module Arel # :nodoc: all end alias :visit_Set :visit_Array + def visit_Arel_Nodes_Comment(o) + visit_edge(o, "values") + end + def visit_edge(o, method) edge(method) { visit o.send(method) } end diff --git a/activerecord/lib/arel/visitors/to_sql.rb b/activerecord/lib/arel/visitors/to_sql.rb index 1630226085..277d553e6c 100644 --- a/activerecord/lib/arel/visitors/to_sql.rb +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -162,7 +162,7 @@ module Arel # :nodoc: all collect_nodes_for o.havings, collector, " HAVING ", " AND " collect_nodes_for o.windows, collector, " WINDOW " - collector + maybe_visit o.comment, collector end def visit_Arel_Nodes_OptimizerHints(o, collector) @@ -170,6 +170,10 @@ module Arel # :nodoc: all 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 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/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 2b20d842e8..ba04859bf0 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -485,23 +485,51 @@ module ActiveRecord end def test_truncate - assert_operator @connection.query_value("SELECT COUNT(*) FROM posts"), :>, 0 + assert_operator Post.count, :>, 0 @connection.truncate("posts") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM posts") + assert_equal 0, Post.count + 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 @connection.query_value("SELECT COUNT(*) FROM posts"), :>, 0 - assert_operator @connection.query_value("SELECT COUNT(*) FROM authors"), :>, 0 - assert_operator @connection.query_value("SELECT COUNT(*) FROM author_addresses"), :>, 0 + assert_operator Post.count, :>, 0 + assert_operator Author.count, :>, 0 + assert_operator AuthorAddress.count, :>, 0 @connection.truncate_tables("author_addresses", "authors", "posts") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM posts") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM authors") - assert_equal 0, @connection.query_value("SELECT COUNT(*) FROM author_addresses") + assert_equal 0, Post.count + assert_equal 0, Author.count + assert_equal 0, AuthorAddress.count + 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 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/datetime_precision_quoting_test.rb b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb index 00a075e063..cbe55f1d53 100644 --- a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb +++ b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb @@ -46,10 +46,7 @@ class Mysql2DatetimePrecisionQuotingTest < ActiveRecord::Mysql2TestCase def stub_version(full_version_string) @connection.stub(:full_version, full_version_string) do - @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) yield end - ensure - @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) end end diff --git a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb index a5b53f76b4..6ade2eec24 100644 --- a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb +++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb @@ -200,7 +200,7 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes @conn.while_preventing_writes do - assert_nil @conn.execute("SET NAMES utf8") + assert_nil @conn.execute("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci") end end diff --git a/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb index b9794c5710..628802b216 100644 --- a/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb +++ b/activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb @@ -15,6 +15,14 @@ if supports_optimizer_hints? end end + def test_optimizer_hints_with_count_subquery + assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do + posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)") + posts = posts.select(:id).where(author_id: [0, 1]).limit(5) + assert_equal 5, posts.count + end + end + def test_optimizer_hints_is_sanitized assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */") diff --git a/activerecord/test/cases/adapters/mysql2/schema_test.rb b/activerecord/test/cases/adapters/mysql2/schema_test.rb index 1283b0642c..b8f51acba0 100644 --- a/activerecord/test/cases/adapters/mysql2/schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb @@ -41,7 +41,7 @@ module ActiveRecord column_24 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_24" } column_25 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_25" } - # Mysql floats are precision 0..24, Mysql doubles are precision 25..53 + # MySQL floats are precision 0..24, MySQL doubles are precision 25..53 assert_equal 24, column_no_limit.limit assert_equal 24, column_short.limit assert_equal 53, column_long.limit diff --git a/activerecord/test/cases/adapters/mysql2/sp_test.rb b/activerecord/test/cases/adapters/mysql2/sp_test.rb index 7b6dce71e9..626ef59570 100644 --- a/activerecord/test/cases/adapters/mysql2/sp_test.rb +++ b/activerecord/test/cases/adapters/mysql2/sp_test.rb @@ -9,7 +9,7 @@ class Mysql2StoredProcedureTest < ActiveRecord::Mysql2TestCase def setup @connection = ActiveRecord::Base.connection - unless ActiveRecord::Base.connection.version >= "5.6.0" + unless ActiveRecord::Base.connection.database_version >= "5.6.0" skip("no stored procedure support") end end diff --git a/activerecord/test/cases/adapters/postgresql/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/extension_migration_test.rb b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb index df97ab11e7..0fd7b2c6ed 100644 --- a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb +++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb @@ -22,23 +22,26 @@ class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase @connection = ActiveRecord::Base.connection - @old_schema_migration_table_name = ActiveRecord::SchemaMigration.table_name @old_table_name_prefix = ActiveRecord::Base.table_name_prefix @old_table_name_suffix = ActiveRecord::Base.table_name_suffix ActiveRecord::Base.table_name_prefix = "p_" ActiveRecord::Base.table_name_suffix = "_s" + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name + ActiveRecord::SchemaMigration.delete_all rescue nil - ActiveRecord::SchemaMigration.table_name = "p_schema_migrations_s" ActiveRecord::Migration.verbose = false end def teardown - ActiveRecord::Base.table_name_prefix = @old_table_name_prefix - ActiveRecord::Base.table_name_suffix = @old_table_name_suffix ActiveRecord::SchemaMigration.delete_all rescue nil ActiveRecord::Migration.verbose = true - ActiveRecord::SchemaMigration.table_name = @old_schema_migration_table_name + + ActiveRecord::Base.table_name_prefix = @old_table_name_prefix + ActiveRecord::Base.table_name_suffix = @old_table_name_suffix + ActiveRecord::SchemaMigration.reset_table_name + ActiveRecord::InternalMetadata.reset_table_name super end diff --git a/activerecord/test/cases/adapters/postgresql/geometric_test.rb b/activerecord/test/cases/adapters/postgresql/geometric_test.rb index 8c6f046553..14c262f4ce 100644 --- a/activerecord/test/cases/adapters/postgresql/geometric_test.rb +++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb @@ -247,7 +247,7 @@ class PostgreSQLGeometricLineTest < ActiveRecord::PostgreSQLTestCase class PostgresqlLine < ActiveRecord::Base; end setup do - unless ActiveRecord::Base.connection.send(:postgresql_version) >= 90400 + unless ActiveRecord::Base.connection.database_version >= 90400 skip("line type is not fully implemented") end @connection = ActiveRecord::Base.connection diff --git a/activerecord/test/cases/adapters/postgresql/hstore_test.rb b/activerecord/test/cases/adapters/postgresql/hstore_test.rb index cd45975f70..671d8211a7 100644 --- a/activerecord/test/cases/adapters/postgresql/hstore_test.rb +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -153,6 +153,22 @@ class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase assert_equal "GMT", y.timezone end + def test_changes_with_store_accessors + x = Hstore.new(language: "de") + assert x.language_changed? + assert_nil x.language_was + assert_equal [nil, "de"], x.language_change + x.save! + + assert_not x.language_changed? + x.reload + + x.settings = nil + assert x.language_changed? + assert_equal "de", x.language_was + assert_equal ["de", nil], x.language_change + end + def test_changes_in_place hstore = Hstore.create!(settings: { "one" => "two" }) hstore.settings["three"] = "four" diff --git a/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb index 5e4bf232e1..5b9f5e0832 100644 --- a/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb +++ b/activerecord/test/cases/adapters/postgresql/optimizer_hints_test.rb @@ -19,6 +19,14 @@ if supports_optimizer_hints? end end + def test_optimizer_hints_with_count_subquery + assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do + posts = Post.optimizer_hints("SeqScan(posts)") + posts = posts.select(:id).where(author_id: [0, 1]).limit(5) + assert_equal 5, posts.count + end + end + def test_optimizer_hints_is_sanitized assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do posts = Post.optimizer_hints("/*+ SeqScan(posts) */") diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb index 0ac9ca1200..4015bc94f9 100644 --- a/activerecord/test/cases/adapters/postgresql/partitions_test.rb +++ b/activerecord/test/cases/adapters/postgresql/partitions_test.rb @@ -12,7 +12,7 @@ class PostgreSQLPartitionsTest < ActiveRecord::PostgreSQLTestCase end def test_partitions_table_exists - skip unless ActiveRecord::Base.connection.postgresql_version >= 100000 + skip unless ActiveRecord::Base.connection.database_version >= 100000 @connection.create_table :partitioned_events, force: true, id: false, options: "partition by range (issued_at)" do |t| t.timestamp :issued_at diff --git a/activerecord/test/cases/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/ar_schema_test.rb b/activerecord/test/cases/ar_schema_test.rb index 9d88b14dab..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 diff --git a/activerecord/test/cases/arel/nodes/comment_test.rb b/activerecord/test/cases/arel/nodes/comment_test.rb new file mode 100644 index 0000000000..bf5eaf4c5a --- /dev/null +++ b/activerecord/test/cases/arel/nodes/comment_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "yaml" + +module Arel + module Nodes + class CommentTest < Arel::Spec + describe "equality" do + it "is equal with equal contents" do + array = [Comment.new(["foo"]), Comment.new(["foo"])] + assert_equal 1, array.uniq.size + end + + it "is not equal with different contents" do + array = [Comment.new(["foo"]), Comment.new(["bar"])] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/select_core_test.rb b/activerecord/test/cases/arel/nodes/select_core_test.rb index 0b698205ff..6860f2a395 100644 --- a/activerecord/test/cases/arel/nodes/select_core_test.rb +++ b/activerecord/test/cases/arel/nodes/select_core_test.rb @@ -37,6 +37,7 @@ module Arel core1.groups = %w[j k l] core1.windows = %w[m n o] core1.havings = %w[p q r] + core1.comment = Arel::Nodes::Comment.new(["comment"]) core2 = SelectCore.new core2.froms = %w[a b c] core2.projections = %w[d e f] @@ -44,6 +45,7 @@ module Arel core2.groups = %w[j k l] core2.windows = %w[m n o] core2.havings = %w[p q r] + core2.comment = Arel::Nodes::Comment.new(["comment"]) array = [core1, core2] assert_equal 1, array.uniq.size end @@ -56,6 +58,7 @@ module Arel core1.groups = %w[j k l] core1.windows = %w[m n o] core1.havings = %w[p q r] + core1.comment = Arel::Nodes::Comment.new(["comment"]) core2 = SelectCore.new core2.froms = %w[a b c] core2.projections = %w[d e f] @@ -63,6 +66,11 @@ module Arel core2.groups = %w[j k l] core2.windows = %w[m n o] core2.havings = %w[l o l] + core2.comment = Arel::Nodes::Comment.new(["comment"]) + array = [core1, core2] + assert_equal 2, array.uniq.size + core2.havings = %w[p q r] + core2.comment = Arel::Nodes::Comment.new(["other"]) array = [core1, core2] assert_equal 2, array.uniq.size end diff --git a/activerecord/test/cases/arel/select_manager_test.rb b/activerecord/test/cases/arel/select_manager_test.rb index 5220950905..e6c49cd429 100644 --- a/activerecord/test/cases/arel/select_manager_test.rb +++ b/activerecord/test/cases/arel/select_manager_test.rb @@ -1221,5 +1221,28 @@ module Arel manager.distinct_on(false).must_equal manager end end + + describe "comment" do + it "chains" do + manager = Arel::SelectManager.new + manager.comment("selecting").must_equal manager + end + + it "appends a comment to the generated query" do + manager = Arel::SelectManager.new + table = Table.new :users + manager.from(table).project(table["id"]) + + manager.comment("selecting") + manager.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" /* selecting */ + } + + manager.comment("selecting", "with", "comment") + manager.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" /* selecting */ /* with */ /* comment */ + } + end + end end end diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb index 559ff5d4e6..18e6c10c9d 100644 --- a/activerecord/test/cases/arel/support/fake_record.rb +++ b/activerecord/test/cases/arel/support/fake_record.rb @@ -58,6 +58,10 @@ module FakeRecord "\"#{name}\"" end + def sanitize_as_sql_comment(comment) + comment + end + def schema_cache self end diff --git a/activerecord/test/cases/arel/visitors/depth_first_test.rb b/activerecord/test/cases/arel/visitors/depth_first_test.rb index 4a57608411..106be2311d 100644 --- a/activerecord/test/cases/arel/visitors/depth_first_test.rb +++ b/activerecord/test/cases/arel/visitors/depth_first_test.rb @@ -101,6 +101,12 @@ module Arel assert_equal [:a, :b, join], @collector.calls end + def test_comment + comment = Nodes::Comment.new ["foo"] + @visitor.accept comment + assert_equal ["foo", ["foo"], comment], @collector.calls + end + [ Arel::Nodes::Assignment, Arel::Nodes::Between, diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index b9e16cab21..49f754be63 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -121,7 +121,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase assert reply.save topics = Topic.all.merge!(includes: :replies, order: ["topics.id", "replies_topics.id"]).to_a - assert_no_queries do + assert_queries(0) do assert_equal 2, topics[0].replies.size assert_equal 0, topics[1].replies.size end diff --git a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb index 5fca972aee..673d5f1dcf 100644 --- a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb +++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb @@ -21,7 +21,7 @@ module PolymorphicFullStiClassNamesSharedTest ActiveRecord::Base.store_full_sti_class = store_full_sti_class post = Namespaced::Post.create(title: "Great stuff", body: "This is not", author_id: 1) - @tagging = Tagging.create(taggable: post) + @tagging = post.create_tagging! end def teardown diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 67e013c6e0..fa540c9dab 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -1474,6 +1474,24 @@ 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 + private def make_model(name) Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index 0b83fd8421..35da74102d 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -548,6 +548,15 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase end end + def test_through_association_preload_doesnt_reset_source_association_if_already_preloaded + blue = tags(:blue) + authors = Author.preload(posts: :first_blue_tags_2, misc_post_first_blue_tags_2: {}).to_a.sort_by(&:id) + + assert_no_queries do + assert_equal [blue], authors[2].posts.first.first_blue_tags_2 + end + end + def test_nested_has_many_through_with_conditions_on_source_associations_preload_via_joins # Pointless condition to force single-query loading assert_includes_and_joins_equal( diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index 081da95df7..84130ec208 100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -21,6 +21,11 @@ require "models/molecule" require "models/electron" require "models/man" require "models/interest" +require "models/pirate" +require "models/parrot" +require "models/bird" +require "models/treasure" +require "models/price_estimate" class AssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :developers_projects, @@ -368,3 +373,97 @@ class GeneratedMethodsTest < ActiveRecord::TestCase assert_equal :none, MyArticle.new.comments end end + +class WithAnnotationsTest < ActiveRecord::TestCase + fixtures :pirates, :parrots + + def test_belongs_to_with_annotation_includes_a_query_comment + pirate = SpacePirate.where.not(parrot_id: nil).first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.parrot + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that tells jokes \*/}) do + pirate.parrot_with_annotation + end + end + + def test_has_and_belongs_to_many_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.parrots.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that are very colorful \*/}) do + pirate.parrots_with_annotation.first + end + end + + def test_has_one_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.ship + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that is a rocket \*/}) do + pirate.ship_with_annotation + end + end + + def test_has_many_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.birds.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* that are also parrots \*/}) do + pirate.birds_with_annotation.first + end + end + + def test_has_many_through_with_annotation_includes_a_query_comment + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.treasure_estimates.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* yarrr \*/}) do + pirate.treasure_estimates_with_annotation.first + end + end + + def test_has_many_through_with_annotation_includes_a_query_comment_when_eager_loading + pirate = SpacePirate.first + assert pirate, "should have a Pirate record" + + log = capture_sql do + pirate.treasure_estimates.first + end + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? + + assert_sql(%r{/\* yarrr \*/}) do + SpacePirate.includes(:treasure_estimates_with_annotation, :treasures).first + end + end +end diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index 54eb885f6a..1a0732c14b 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "models/author" require "models/bird" require "models/post" require "models/comment" @@ -1319,21 +1320,45 @@ end class TestAutosaveAssociationOnAHasOneThroughAssociation < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? - def setup - super + def create_member_with_organization organization = Organization.create - @member = Member.create - MemberDetail.create(organization: organization, member: @member) + member = Member.create + MemberDetail.create(organization: organization, member: member) + + member end def test_should_not_has_one_through_model - class << @member.organization + member = create_member_with_organization + + class << member.organization + def save(*args) + super + raise "Oh noes!" + end + end + assert_nothing_raised { member.save } + end + + def create_author_with_post_with_comment + Author.create! name: "David" # make comment_id not match author_id + author = Author.create! name: "Sergiy" + post = Post.create! author: author, title: "foo", body: "bar" + Comment.create! post: post, body: "cool comment" + + author + end + + def test_should_not_reversed_has_one_through_model + author = create_author_with_post_with_comment + + class << author.comment_on_first_post def save(*args) super raise "Oh noes!" end end - assert_nothing_raised { @member.save } + assert_nothing_raised { author.save } end end diff --git a/activerecord/test/cases/boolean_test.rb b/activerecord/test/cases/boolean_test.rb index ab9f974e2c..18824004d2 100644 --- a/activerecord/test/cases/boolean_test.rb +++ b/activerecord/test/cases/boolean_test.rb @@ -40,4 +40,13 @@ class BooleanTest < ActiveRecord::TestCase assert_equal b_false, Boolean.find_by(value: "false") assert_equal b_true, Boolean.find_by(value: "true") end + + def test_find_by_falsy_boolean_symbol + ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| + b_false = Boolean.create!(value: value) + + assert_not_predicate b_false, :value? + assert_equal b_false, Boolean.find_by(id: b_false.id, value: value.to_s.to_sym) + end + end end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index f1e35d6ab9..c4070fc341 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -243,6 +243,12 @@ class CalculationsTest < ActiveRecord::TestCase assert_queries(1) { assert_equal 11, posts.count(:all) } end + def test_count_with_eager_loading_and_custom_select_and_order + posts = Post.includes(:comments).order("comments.id").select(:type) + assert_queries(1) { assert_equal 11, posts.count } + assert_queries(1) { assert_equal 11, posts.count(:all) } + end + def test_count_with_eager_loading_and_custom_order_and_distinct posts = Post.includes(:comments).order("comments.id").distinct assert_queries(1) { assert_equal 11, posts.count } diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index 5113548091..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 @@ -28,6 +29,7 @@ module ActiveRecord assert new_cache.data_sources("posts") assert_equal "id", new_cache.primary_keys("posts") assert_equal 1, new_cache.indexes("posts").size + assert_equal @database_version.to_s, new_cache.database_version.to_s end end @@ -55,6 +57,20 @@ module ActiveRecord @connection.schema_cache = old_cache end + def test_yaml_loads_5_1_dump_without_database_version_still_queries_for_database_version + @cache = YAML.load(File.read(schema_dump_path)) + + # Simulate assignment in railtie after loading the cache. + old_cache, @connection.schema_cache = @connection.schema_cache, @cache + + # We can't verify queries get executed because the database version gets + # cached in both MySQL and PostgreSQL outside of the schema cache. + assert_nil @cache.instance_variable_get(:@database_version) + assert_equal @database_version.to_s, @cache.database_version.to_s + ensure + @connection.schema_cache = old_cache + end + def test_primary_key_for_non_existent_table assert_nil @cache.primary_keys("omgponies") end @@ -74,6 +90,14 @@ module ActiveRecord assert_equal indexes, @cache.indexes("posts") end + def test_caches_database_version + @cache.database_version # cache database_version + + assert_no_queries do + assert_equal @database_version.to_s, @cache.database_version.to_s + end + end + def test_clearing @cache.columns("posts") @cache.columns_hash("posts") @@ -84,6 +108,7 @@ module ActiveRecord @cache.clear! assert_equal 0, @cache.size + assert_nil @cache.instance_variable_get(:@database_version) end def test_dump_and_load @@ -101,6 +126,7 @@ module ActiveRecord assert @cache.data_sources("posts") assert_equal "id", @cache.primary_keys("posts") assert_equal 1, @cache.indexes("posts").size + assert_equal @database_version.to_s, @cache.database_version.to_s end end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb index 5d02e59ef6..50a86b0a19 100644 --- a/activerecord/test/cases/defaults_test.rb +++ b/activerecord/test/cases/defaults_test.rb @@ -89,7 +89,7 @@ if current_adapter?(:PostgreSQLAdapter) test "schema dump includes default expression" do output = dump_table_schema("defaults") - if ActiveRecord::Base.connection.postgresql_version >= 100000 + if ActiveRecord::Base.connection.database_version >= 100000 assert_match %r/t\.date\s+"modified_date",\s+default: -> { "CURRENT_DATE" }/, output assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "CURRENT_TIMESTAMP" }/, output else diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb index f95d082907..543a0aeb39 100644 --- a/activerecord/test/cases/helper.rb +++ b/activerecord/test/cases/helper.rb @@ -53,7 +53,7 @@ def supports_default_expression? true elsif current_adapter?(:Mysql2Adapter) conn = ActiveRecord::Base.connection - !conn.mariadb? && conn.version >= "8.0.13" + !conn.mariadb? && conn.database_version >= "8.0.13" end end @@ -202,3 +202,5 @@ module InTimeZone ActiveRecord::Base.time_zone_aware_attributes = old_tz end end + +require_relative "../../../tools/test_common" diff --git a/activerecord/test/cases/insert_all_test.rb b/activerecord/test/cases/insert_all_test.rb index 0818d7c1ab..fc25701c80 100644 --- a/activerecord/test/cases/insert_all_test.rb +++ b/activerecord/test/cases/insert_all_test.rb @@ -11,6 +11,20 @@ class InsertAllTest < ActiveRecord::TestCase fixtures :books def test_insert + skip unless supports_insert_on_duplicate_skip? + + id = 1_000_000 + + assert_difference "Book.count", +1 do + Book.insert(id: id, name: "Rework", author_id: 1) + end + + Book.upsert(id: id, name: "Remote", author_id: 1) + + assert_equal "Remote", Book.find(id).name + end + + def test_insert! assert_difference "Book.count", +1 do Book.insert! name: "Rework", author_id: 1 end diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb index dbbba9c5fa..cce3461e18 100644 --- a/activerecord/test/cases/migration/columns_test.rb +++ b/activerecord/test/cases/migration/columns_test.rb @@ -136,7 +136,7 @@ module ActiveRecord def test_remove_column_with_multi_column_index # MariaDB starting with 10.2.8 # Dropping a column that is part of a multi-column UNIQUE constraint is not permitted. - skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.version >= "10.2.8" + skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.database_version >= "10.2.8" add_column "test_models", :hat_size, :integer add_column "test_models", :hat_style, :string, limit: 100 diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index ba21923d79..5f1057f093 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -155,7 +155,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? class ForeignKeyChangeColumnWithSuffixTest < ForeignKeyChangeColumnTest setup do - ActiveRecord::Base.table_name_suffix = "_p" + ActiveRecord::Base.table_name_suffix = "_s" end teardown do diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index 788c8c36b8..f10c26c308 100644 --- a/activerecord/test/cases/migration_test.rb +++ b/activerecord/test/cases/migration_test.rb @@ -385,6 +385,7 @@ class MigrationTest < ActiveRecord::TestCase assert_equal "changed", ActiveRecord::SchemaMigration.table_name ensure ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name + ActiveRecord::SchemaMigration.reset_table_name Reminder.reset_table_name end @@ -405,6 +406,7 @@ class MigrationTest < ActiveRecord::TestCase assert_equal "changed", ActiveRecord::InternalMetadata.table_name ensure ActiveRecord::Base.internal_metadata_table_name = original_internal_metadata_table_name + ActiveRecord::InternalMetadata.reset_table_name Reminder.reset_table_name end diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb index 224e4f39a8..5c5e760e34 100644 --- a/activerecord/test/cases/relation/merging_test.rb +++ b/activerecord/test/cases/relation/merging_test.rb @@ -135,6 +135,18 @@ class RelationMergingTest < ActiveRecord::TestCase relation = Post.all.merge(Post.order(Arel.sql("title LIKE '%?'"))) assert_equal ["title LIKE '%?'"], relation.order_values end + + def test_merging_annotations_respects_merge_order + assert_sql(%r{/\* foo \*/ /\* bar \*/}) do + Post.annotate("foo").merge(Post.annotate("bar")).first + end + assert_sql(%r{/\* bar \*/ /\* foo \*/}) do + Post.annotate("bar").merge(Post.annotate("foo")).first + end + assert_sql(%r{/\* foo \*/ /\* bar \*/ /\* baz \*/ /\* qux \*/}) do + Post.annotate("foo").annotate("bar").merge(Post.annotate("baz").annotate("qux")).first + end + end end class MergingDifferentRelationsTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 68161f6a84..00a7b3841f 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -307,6 +307,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 36b4000018..2417775ef1 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -602,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") @@ -978,6 +985,12 @@ class RelationTest < ActiveRecord::TestCase assert_queries(1) { assert_equal 11, posts.load.size } end + def test_size_with_eager_loading_and_custom_select_and_order + posts = Post.includes(:comments).order("comments.id").select(:type) + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + end + def test_size_with_eager_loading_and_custom_order_and_distinct posts = Post.includes(:comments).order("comments.id").distinct assert_queries(1) { assert_equal 11, posts.size } diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb index 8b08e40468..3488442cab 100644 --- a/activerecord/test/cases/scoping/named_scoping_test.rb +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -602,4 +602,14 @@ class NamedScopingTest < ActiveRecord::TestCase Topic.create! assert_predicate Topic, :one? end + + def test_scope_with_annotation + Topic.class_eval do + scope :including_annotate_in_scope, Proc.new { annotate("from-scope") } + end + + assert_sql(%r{/\* from-scope \*/}) do + assert Topic.including_annotate_in_scope.to_a, Topic.all.to_a + end + end end diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb index b1f2ffe29c..a95ab0f429 100644 --- a/activerecord/test/cases/scoping/relation_scoping_test.rb +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -130,6 +130,44 @@ class RelationScopingTest < ActiveRecord::TestCase end end + def test_scoped_find_with_annotation + Developer.annotate("scoped").scoping do + developer = nil + assert_sql(%r{/\* scoped \*/}) do + developer = Developer.where("name = 'David'").first + end + assert_equal "David", developer.name + end + end + + def test_find_with_annotation_unscoped + Developer.annotate("scoped").unscoped do + developer = nil + log = capture_sql do + developer = Developer.where("name = 'David'").first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\* scoped \*/}) }, :empty? + + assert_equal "David", developer.name + end + end + + def test_find_with_annotation_unscope + developer = nil + log = capture_sql do + developer = Developer.annotate("unscope"). + where("name = 'David'"). + unscope(:annotate).first + end + + assert_not_predicate log, :empty? + assert_predicate log.select { |query| query.match?(%r{/\* unscope \*/}) }, :empty? + + assert_equal "David", developer.name + end + def test_scoped_find_include # with the include, will retrieve only developers for the given project scoped_developers = Developer.includes(:projects).scoping do diff --git a/activerecord/test/cases/store_test.rb b/activerecord/test/cases/store_test.rb index 4457cfbd37..91c0e959f4 100644 --- a/activerecord/test/cases/store_test.rb +++ b/activerecord/test/cases/store_test.rb @@ -79,6 +79,74 @@ class StoreTest < ActiveRecord::TestCase assert_not_predicate @john, :settings_changed? end + test "updating the store will mark accessor as changed" do + @john.color = "red" + assert @john.color_changed? + end + + test "new record and no accessors changes" do + user = Admin::User.new + assert_not user.color_changed? + assert_nil user.color_was + assert_nil user.color_change + + user.color = "red" + assert user.color_changed? + assert_nil user.color_was + assert_equal "red", user.color_change[1] + end + + test "updating the store won't mark accessor as changed if the whole store was updated" do + @john.settings = { color: @john.color, some: "thing" } + assert @john.settings_changed? + assert_not @john.color_changed? + end + + test "updating the store populates the accessor changed array correctly" do + @john.color = "red" + assert_equal "black", @john.color_was + assert_equal "black", @john.color_change[0] + assert_equal "red", @john.color_change[1] + end + + test "updating the store won't mark accessor as changed if the value isn't changed" do + @john.color = @john.color + assert_not @john.color_changed? + end + + test "nullifying the store mark accessor as changed" do + color = @john.color + @john.settings = nil + assert @john.color_changed? + assert_equal color, @john.color_was + assert_equal [color, nil], @john.color_change + end + + test "dirty methods for suffixed accessors" do + @john.configs[:two_factor_auth] = true + assert @john.two_factor_auth_configs_changed? + assert_nil @john.two_factor_auth_configs_was + assert_equal [nil, true], @john.two_factor_auth_configs_change + end + + test "dirty methods for prefixed accessors" do + @john.spouse[:name] = "Lena" + assert @john.partner_name_changed? + assert_equal "Dallas", @john.partner_name_was + assert_equal ["Dallas", "Lena"], @john.partner_name_change + end + + test "saved changes tracking for accessors" do + @john.spouse[:name] = "Lena" + assert @john.partner_name_changed? + + @john.save! + assert_not @john.partner_name_change + assert @john.saved_change_to_partner_name? + assert_equal ["Dallas", "Lena"], @john.saved_change_to_partner_name + assert_equal "Dallas", @john.partner_name_before_last_save + end + test "object initialization with not nullable column" do assert_equal true, @john.remember_login end diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 06f11108f9..dd4a0b0455 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -50,6 +50,8 @@ module ActiveRecord protected_environments = ActiveRecord::Base.protected_environments current_env = ActiveRecord::Base.connection.migration_context.current_environment + InternalMetadata[:environment] = current_env + assert_called_on_instance_of( ActiveRecord::MigrationContext, :current_version, @@ -73,6 +75,9 @@ module ActiveRecord def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_symbol protected_environments = ActiveRecord::Base.protected_environments current_env = ActiveRecord::Base.connection.migration_context.current_environment + + InternalMetadata[:environment] = current_env + assert_called_on_instance_of( ActiveRecord::MigrationContext, :current_version, @@ -951,11 +956,22 @@ module ActiveRecord fixtures :authors, :author_addresses + def setup + SchemaMigration.create_table + SchemaMigration.create!(version: SchemaMigration.table_name) + InternalMetadata.create_table + InternalMetadata.create!(key: InternalMetadata.table_name) + end + def teardown + SchemaMigration.delete_all + InternalMetadata.delete_all ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } end def test_truncate_tables + assert_operator SchemaMigration.count, :>, 0 + assert_operator InternalMetadata.count, :>, 0 assert_operator Author.count, :>, 0 assert_operator AuthorAddress.count, :>, 0 @@ -969,12 +985,46 @@ module ActiveRecord ) end + assert_operator SchemaMigration.count, :>, 0 + assert_operator InternalMetadata.count, :>, 0 assert_equal 0, Author.count assert_equal 0, AuthorAddress.count ensure ActiveRecord::Base.configurations = old_configurations end end + + class DatabaseTasksTruncateAllWithPrefixTest < DatabaseTasksTruncateAllTest + setup do + ActiveRecord::Base.table_name_prefix = "p_" + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + + teardown do + ActiveRecord::Base.table_name_prefix = nil + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + end + + class DatabaseTasksTruncateAllWithSuffixTest < DatabaseTasksTruncateAllTest + setup do + ActiveRecord::Base.table_name_suffix = "_s" + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + + teardown do + ActiveRecord::Base.table_name_suffix = nil + + SchemaMigration.reset_table_name + InternalMetadata.reset_table_name + end + end end class DatabaseTasksTruncateAllWithMultipleDatabasesTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb index aa6b7915a2..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 diff --git a/activerecord/test/models/pirate.rb b/activerecord/test/models/pirate.rb index fd5083e597..8733398697 100644 --- a/activerecord/test/models/pirate.rb +++ b/activerecord/test/models/pirate.rb @@ -98,3 +98,19 @@ class FamousPirate < ActiveRecord::Base has_many :famous_ships validates_presence_of :catchphrase, on: :conference end + +class SpacePirate < ActiveRecord::Base + self.table_name = "pirates" + + belongs_to :parrot + belongs_to :parrot_with_annotation, -> { annotate("that tells jokes") }, class_name: :Parrot, foreign_key: :parrot_id + has_and_belongs_to_many :parrots, foreign_key: :pirate_id + has_and_belongs_to_many :parrots_with_annotation, -> { annotate("that are very colorful") }, class_name: :Parrot, foreign_key: :pirate_id + has_one :ship, foreign_key: :pirate_id + has_one :ship_with_annotation, -> { annotate("that is a rocket") }, class_name: :Ship, foreign_key: :pirate_id + has_many :birds, foreign_key: :pirate_id + has_many :birds_with_annotation, -> { annotate("that are also parrots") }, class_name: :Bird, foreign_key: :pirate_id + has_many :treasures, as: :looter + has_many :treasure_estimates, through: :treasures, source: :price_estimates + has_many :treasure_estimates_with_annotation, -> { annotate("yarrr") }, through: :treasures, source: :price_estimates +end |