diff options
Diffstat (limited to 'activerecord/lib')
56 files changed, 1099 insertions, 444 deletions
diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 7bdbd8ce69..64c20adc87 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -703,8 +703,9 @@ module ActiveRecord # #belongs_to associations. # # Extra options on the associations, as defined in the - # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> constant, will - # also prevent the association's inverse from being found automatically. + # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> + # constant, or a custom scope, will also prevent the association's inverse + # from being found automatically. # # The automatic guessing of the inverse association uses a heuristic based # on the name of the class, so it may not work for all associations, diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index ff57c40121..5848cd9112 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -20,10 +20,10 @@ module ActiveRecord::Associations::Builder # :nodoc: } end - def self.define_extensions(model, name) + def self.define_extensions(model, name, &block) if block_given? extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension" - extension = Module.new(&Proc.new) + extension = Module.new(&block) model.module_parent.const_set(extension_module_name, extension) end end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index 25254e652a..32653956b2 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -14,6 +14,7 @@ module ActiveRecord owners.each do |owner| through_records = Array(owner.association(through_reflection.name).target) + if already_loaded if source_type = reflection.options[:source_type] through_records = through_records.select do |record| @@ -23,17 +24,20 @@ module ActiveRecord else owner.association(through_reflection.name).reset if through_scope end + result = through_records.flat_map do |record| record.association(source_reflection.name).target 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 + owner.association(source_reflection.name).reset if owner end end end diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb index 6757e9b66a..6811f54b10 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -16,8 +16,7 @@ module ActiveRecord when true then true when false, nil then false else - column = self.class.columns_hash[attr_name] - if column.nil? + if !type_for_attribute(attr_name) { false } if Numeric === value || value !~ /[^0-9]/ !value.to_i.zero? else diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index aa2ecee74a..6aacbe5f88 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -20,9 +20,22 @@ module ActiveRecord raise "Passing bind parameters with an arel AST is forbidden. " \ "The values must be stored on the AST directly" end - sql, binds = visitor.compile(arel_or_sql_string.ast, collector) - [sql.freeze, binds || []] + + if prepared_statements + sql, binds = visitor.compile(arel_or_sql_string.ast, collector) + + if binds.length > bind_params_length + unprepared_statement do + sql, binds = to_sql_and_binds(arel_or_sql_string) + visitor.preparable = false + end + end + else + sql = visitor.compile(arel_or_sql_string.ast, collector) + end + [sql.freeze, binds] else + visitor.preparable = false if prepared_statements [arel_or_sql_string.dup.freeze, binds] end end @@ -47,13 +60,8 @@ module ActiveRecord arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) - if !prepared_statements || (arel.is_a?(String) && preparable.nil?) - preparable = false - elsif binds.length > bind_params_length - sql, binds = unprepared_statement { to_sql_and_binds(arel) } - preparable = false - else - preparable = visitor.preparable + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false end if prepared_statements && preparable @@ -134,11 +142,6 @@ module ActiveRecord exec_query(sql, name, binds) end - # Executes the truncate statement. - def truncate(table_name, name = nil) - raise NotImplementedError - end - # Executes update +sql+ statement in the context of this connection using # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. @@ -173,6 +176,23 @@ module ActiveRecord exec_delete(sql, name, binds) end + # Executes the truncate statement. + def truncate(table_name, name = nil) + execute(build_truncate_statements(table_name), name) + end + + def truncate_tables(*table_names) # :nodoc: + return if table_names.empty? + + with_multi_statements do + disable_referential_integrity do + Array(build_truncate_statements(*table_names)).each do |sql| + execute_batch(sql, "Truncate Tables") + end + end + end + end + # Runs the given block in a database transaction, and returns the result # of the block. # @@ -333,46 +353,20 @@ module ActiveRecord # We keep this method to provide fallback # for databases like sqlite that do not support bulk inserts. def insert_fixture(fixture, table_name) - fixture = fixture.stringify_keys - - columns = schema_cache.columns_hash(table_name) - binds = fixture.map do |name, value| - if column = columns[name] - type = lookup_cast_type_from_column(column) - Relation::QueryAttribute.new(name, value, type) - else - raise Fixture::FixtureError, %(table "#{table_name}" has no column named #{name.inspect}.) - end - end - - table = Arel::Table.new(table_name) - - values = binds.map do |bind| - value = with_yaml_fallback(bind.value_for_database) - [table[bind.name], value] - end - - manager = Arel::InsertManager.new - manager.into(table) - manager.insert(values) - execute manager.to_sql, "Fixture Insert" + execute(build_fixture_sql(Array.wrap(fixture), table_name), "Fixture Insert") end def insert_fixtures_set(fixture_set, tables_to_delete = []) - fixture_inserts = fixture_set.map do |table_name, fixtures| - next if fixtures.empty? - - build_fixture_sql(fixtures, table_name) - end.compact - - table_deletes = tables_to_delete.map { |table| +"DELETE FROM #{quote_table_name table}" } - total_sql = Array.wrap(combine_multi_statements(table_deletes + fixture_inserts)) - - disable_referential_integrity do - transaction(requires_new: true) do - total_sql.each do |sql| - execute sql, "Fixtures Load" - yield if block_given? + fixture_inserts = build_fixture_statements(fixture_set) + table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name(table)}" } + total_sql = Array(combine_multi_statements(table_deletes + fixture_inserts)) + + with_multi_statements do + disable_referential_integrity do + transaction(requires_new: true) do + total_sql.each do |sql| + execute_batch(sql, "Fixtures Load") + end end end end @@ -396,15 +390,33 @@ module ActiveRecord end end + # Fixture value is quoted by Arel, however scalar values + # are not quotable. In this case we want to convert + # the column value to YAML. + def with_yaml_fallback(value) # :nodoc: + if value.is_a?(Hash) || value.is_a?(Array) + YAML.dump(value) + else + value + end + end + private + def execute_batch(sql, name = nil) + execute(sql, name) + end + + DEFAULT_INSERT_VALUE = Arel.sql("DEFAULT").freeze + private_constant :DEFAULT_INSERT_VALUE + def default_insert_value(column) - Arel.sql("DEFAULT") + DEFAULT_INSERT_VALUE end def build_fixture_sql(fixtures, table_name) columns = schema_cache.columns_hash(table_name) - values = fixtures.map do |fixture| + values_list = fixtures.map do |fixture| fixture = fixture.stringify_keys unknown_columns = fixture.keys - columns.keys @@ -426,12 +438,43 @@ module ActiveRecord table = Arel::Table.new(table_name) manager = Arel::InsertManager.new manager.into(table) - columns.each_key { |column| manager.columns << table[column] } - manager.values = manager.create_values_list(values) + if values_list.size == 1 + values = values_list.shift + new_values = [] + columns.each_key.with_index { |column, i| + unless values[i].equal?(DEFAULT_INSERT_VALUE) + new_values << values[i] + manager.columns << table[column] + end + } + values_list << new_values + else + columns.each_key { |column| manager.columns << table[column] } + end + + manager.values = manager.create_values_list(values_list) manager.to_sql end + def build_fixture_statements(fixture_set) + fixture_set.map do |table_name, fixtures| + next if fixtures.empty? + build_fixture_sql(fixtures, table_name) + end.compact + end + + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "TRUNCATE TABLE #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + + def with_multi_statements + yield + end + def combine_multi_statements(total_sql) total_sql.join(";\n") end @@ -465,17 +508,6 @@ module ActiveRecord relation end end - - # Fixture value is quoted by Arel, however scalar values - # are not quotable. In this case we want to convert - # the column value to YAML. - def with_yaml_fallback(value) - if value.is_a?(Hash) || value.is_a?(Array) - YAML.dump(value) - else - value - end - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index d950099bab..93b1c4e632 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -97,9 +97,8 @@ module ActiveRecord arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) - if binds.length > bind_params_length - sql, binds = unprepared_statement { to_sql_and_binds(arel) } - preparable = false + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false end cache_sql(sql, name, binds) { super(sql, name, binds, preparable: preparable) } diff --git a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb index eefe621feb..9d24d839c1 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb @@ -138,15 +138,15 @@ module ActiveRecord "'#{quote_string(value.to_s)}'" end - def type_casted_binds(binds) # :nodoc: - if binds.first.is_a?(Array) - binds.map { |column, value| type_cast(value, column) } - else - binds.map { |attr| type_cast(attr.value_for_database) } + private + def type_casted_binds(binds) + if binds.first.is_a?(Array) + binds.map { |column, value| type_cast(value, column) } + else + binds.map { |attr| type_cast(attr.value_for_database) } + end end - end - private def lookup_cast_type(sql_type) type_map.lookup(sql_type) end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 11d4b4a503..4861872129 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -105,13 +105,9 @@ module ActiveRecord !ActiveRecord::SchemaDumper.fk_ignore_pattern.match?(name) if name end - def defined_for?(to_table_ord = nil, to_table: nil, **options) - if to_table_ord - self.to_table == to_table_ord.to_s - else - (to_table.nil? || to_table.to_s == self.to_table) && - options.all? { |k, v| self.options[k].to_s == v.to_s } - end + def defined_for?(to_table: nil, **options) + (to_table.nil? || to_table.to_s == self.to_table) && + options.all? { |k, v| self.options[k].to_s == v.to_s } end private 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 b202a82f55..6981ea6ecd 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -852,7 +852,7 @@ module ActiveRecord # [<tt>:null</tt>] # Whether the column allows nulls. Defaults to true. # - # ====== Create a user_id bigint column without a index + # ====== Create a user_id bigint column without an index # # add_reference(:products, :user, index: false) # @@ -1002,10 +1002,10 @@ module ActiveRecord # with an addition of # [<tt>:to_table</tt>] # The name of the table that contains the referenced primary key. - def remove_foreign_key(from_table, options_or_to_table = {}) + def remove_foreign_key(from_table, to_table = nil, **options) return unless supports_foreign_keys? - fk_name_to_delete = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_delete = foreign_key_for!(from_table, to_table: to_table, **options).name at = create_alter_table from_table at.drop_foreign_key fk_name_to_delete @@ -1024,8 +1024,8 @@ module ActiveRecord # # Checks to see if a foreign key with a custom name exists. # foreign_key_exists?(:accounts, name: "special_fk_name") # - def foreign_key_exists?(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table).present? + def foreign_key_exists?(from_table, to_table = nil, **options) + foreign_key_for(from_table, to_table: to_table, **options).present? end def foreign_key_column_for(table_name) # :nodoc: @@ -1051,7 +1051,7 @@ module ActiveRecord def assume_migrated_upto_version(version, migrations_paths = nil) unless migrations_paths.nil? - ActiveSupport::Deprecation.warn(<<~MSG) + ActiveSupport::Deprecation.warn(<<~MSG.squish) Passing migrations_paths to #assume_migrated_upto_version is deprecated and will be removed in Rails 6.1. MSG end @@ -1341,14 +1341,14 @@ module ActiveRecord end end - def foreign_key_for(from_table, options_or_to_table = {}) + def foreign_key_for(from_table, **options) return unless supports_foreign_keys? - foreign_keys(from_table).detect { |fk| fk.defined_for? options_or_to_table } + foreign_keys(from_table).detect { |fk| fk.defined_for?(options) } end - def foreign_key_for!(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table) || \ - raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options_or_to_table}") + def foreign_key_for!(from_table, to_table: nil, **options) + foreign_key_for(from_table, to_table: to_table, **options) || + raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") end def extract_foreign_key_action(specifier) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index c1d846f58d..7aad306d50 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -102,7 +102,7 @@ module ActiveRecord end def self.build_read_query_regexp(*parts) # :nodoc: - parts = parts.map { |part| /\A\s*#{part}/i } + parts = parts.map { |part| /\A[\(\s]*#{part}/i } Regexp.union(*parts) end @@ -120,6 +120,7 @@ module ActiveRecord @quoted_column_names, @quoted_table_names = {}, {} @prevent_writes = false @visitor = arel_visitor + @statements = build_statement_pool @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) @@ -383,10 +384,31 @@ module ActiveRecord false end + # Does this adapter support optimizer hints? + def supports_optimizer_hints? + false + end + def supports_lazy_transactions? false end + def supports_insert_returning? + false + end + + def supports_insert_on_duplicate_skip? + false + end + + def supports_insert_on_duplicate_update? + false + end + + def supports_insert_conflict_target? + false + end + # This is meant to be implemented by the adapters that support extensions def disable_extension(name) end @@ -476,11 +498,9 @@ module ActiveRecord # this should be overridden by concrete adapters end - ### - # Clear any caching the database adapter may be doing, for example - # clearing the prepared statement cache. This is database specific. + # Clear any caching the database adapter may be doing. def clear_cache! - # this should be overridden by concrete adapters + @lock.synchronize { @statements.clear } if @statements end # Returns true if its required to reload the connection between requests for development mode. @@ -506,8 +526,8 @@ module ActiveRecord @connection end - def default_uniqueness_comparison(attribute, value) # :nodoc: - case_sensitive_comparison(attribute, value) + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + attribute.eq(value) end def case_sensitive_comparison(attribute, value) # :nodoc: @@ -542,6 +562,19 @@ module ActiveRecord index.using.nil? end + # Called by ActiveRecord::InsertAll, + # Passed an instance of ActiveRecord::InsertAll::Builder, + # This method implements standard bulk inserts for all databases, but + # should be overridden by adapters to implement common features with + # non-standard syntax like handling duplicates or returning values. + def build_insert_sql(insert) # :nodoc: + if insert.skip_duplicates? || insert.update_duplicates? + raise NotImplementedError, "#{self.class} should define `build_insert_sql` to implement adapter-specific logic for handling duplicates during INSERT" + end + + "INSERT #{insert.into} #{insert.values_list}" + end + private def check_version end @@ -689,6 +722,9 @@ module ActiveRecord def arel_visitor Arel::Visitors::ToSql.new(self) end + + def build_statement_pool + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index 246c6b5123..8ca2cfa9ed 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -53,8 +53,6 @@ module ActiveRecord def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) end def version #:nodoc: @@ -105,10 +103,23 @@ module ActiveRecord mariadb? || 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" + end + def supports_advisory_locks? true end + def supports_insert_on_duplicate_skip? + true + end + + def supports_insert_on_duplicate_update? + true + end + def get_advisory_lock(lock_name, timeout = 0) # :nodoc: query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1 end @@ -154,10 +165,9 @@ module ActiveRecord # CONNECTION MANAGEMENT ==================================== - # Clears the prepared statements cache. - def clear_cache! + def clear_cache! # :nodoc: reload_type_map - @statements.clear + super end #-- @@ -264,10 +274,6 @@ module ActiveRecord show_variable "collation_database" end - def truncate(table_name, name = nil) - execute "TRUNCATE TABLE #{quote_table_name(table_name)}", name - end - def table_comment(table_name) # :nodoc: scope = quoted_scope(table_name) @@ -431,30 +437,6 @@ module ActiveRecord table_options end - # Maps logical Rails types to MySQL-specific data types. - def type_to_sql(type, limit: nil, precision: nil, scale: nil, unsigned: nil, **) # :nodoc: - sql = \ - case type.to_s - when "integer" - integer_to_sql(limit) - when "text" - text_to_sql(limit) - when "blob" - binary_to_sql(limit) - when "binary" - if (0..0xfff) === limit - "varbinary(#{limit})" - else - binary_to_sql(limit) - end - else - super - end - - sql = "#{sql} unsigned" if unsigned && type != :primary_key - sql - end - # SHOW VARIABLES LIKE 'name' def show_variable(name) query_value("SELECT @@#{name}", "SCHEMA") @@ -477,6 +459,21 @@ module ActiveRecord SQL end + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + column = column_for_attribute(attribute) + + if column.collation && !column.case_sensitive? && !value.nil? + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. + To continue case sensitive comparison on the :#{attribute.name} attribute in #{klass} model, + pass `case_sensitive: true` option explicitly to the uniqueness validator. + MSG + attribute.eq(Arel::Nodes::Bin.new(value)) + else + super + end + end + def case_sensitive_comparison(attribute, value) # :nodoc: column = column_for_attribute(attribute) @@ -515,10 +512,18 @@ module ActiveRecord index.using == :btree || super end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - with_multi_statements do - super { discard_remaining_results } + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + any_column = quote_column_name(insert.model.columns.first.name) + sql << " ON DUPLICATE KEY UPDATE #{any_column}=#{any_column}" + elsif insert.update_duplicates? + sql << " ON DUPLICATE KEY UPDATE " + sql << insert.updatable_columns.map { |column| "#{column}=VALUES(#{column})" }.join(",") end + + sql end private @@ -528,33 +533,6 @@ module ActiveRecord end end - def combine_multi_statements(total_sql) - total_sql.each_with_object([]) do |sql, total_sql_chunks| - previous_packet = total_sql_chunks.last - sql << ";\n" - if max_allowed_packet_reached?(sql, previous_packet) || total_sql_chunks.empty? - total_sql_chunks << sql - else - previous_packet << sql - end - end - end - - def max_allowed_packet_reached?(current_packet, previous_packet) - if current_packet.bytesize > max_allowed_packet - raise ActiveRecordError, "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." - elsif previous_packet.nil? - false - else - (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet - end - end - - def max_allowed_packet - bytes_margin = 2 - @max_allowed_packet ||= (show_variable("max_allowed_packet") - bytes_margin) - end - def initialize_type_map(m = type_map) super @@ -794,6 +772,10 @@ module ActiveRecord Arel::Visitors::MySQL.new(self) end + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + def mismatched_foreign_key(message, sql:, binds:) match = %r/ (?:CREATE|ALTER)\s+TABLE\s*(?:`?\w+`?\.)?`?(?<table>\w+)`?.+? @@ -818,37 +800,6 @@ module ActiveRecord MismatchedForeignKey.new(options) end - def integer_to_sql(limit) # :nodoc: - case limit - when 1; "tinyint" - when 2; "smallint" - when 3; "mediumint" - when nil, 4; "int" - when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead.") - end - end - - def text_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinytext" - when nil, 0x100..0xffff; "text" - when 0x10000..0xffffff; "mediumtext" - when 0x1000000..0xffffffff; "longtext" - else raise(ActiveRecordError, "No text type has byte length #{limit}") - end - end - - def binary_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinyblob" - when nil, 0x100..0xffff; "blob" - when 0x10000..0xffffff; "mediumblob" - when 0x1000000..0xffffffff; "longblob" - else raise(ActiveRecordError, "No binary type has byte length #{limit}") - end - end - def version_string full_version.match(/^(?:5\.5\.5-)?(\d+\.\d+\.\d+)/)[1] end diff --git a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb index 883747b84b..1df4dea2d8 100644 --- a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb +++ b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb @@ -3,7 +3,7 @@ module ActiveRecord module ConnectionAdapters module DetermineIfPreparableVisitor - attr_reader :preparable + attr_accessor :preparable def accept(*) @preparable = true diff --git a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb index 6adcc14545..1199c0ad1b 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -11,7 +11,7 @@ module ActiveRecord else super end - discard_remaining_results + @connection.abandon_results! result end @@ -69,22 +69,31 @@ module ActiveRecord alias :exec_update :exec_delete private + def execute_batch(sql, name = nil) + super + @connection.abandon_results! + end + def default_insert_value(column) - Arel.sql("DEFAULT") unless column.auto_increment? + super unless column.auto_increment? end def last_inserted_id(result) @connection.last_id end - def discard_remaining_results - @connection.abandon_results! - end - def supports_set_server_option? @connection.respond_to?(:set_server_option) end + def build_truncate_statements(*table_names) + if table_names.size == 1 + super.first + else + super + end + end + def multi_statements_enabled?(flags) if flags.is_a?(Array) flags.include?("MULTI_STATEMENTS") @@ -117,6 +126,36 @@ module ActiveRecord end end + def combine_multi_statements(total_sql) + total_sql.each_with_object([]) do |sql, total_sql_chunks| + previous_packet = total_sql_chunks.last + if max_allowed_packet_reached?(sql, previous_packet) + total_sql_chunks << +sql + else + previous_packet << ";\n" + previous_packet << sql + end + end + end + + def max_allowed_packet_reached?(current_packet, previous_packet) + if current_packet.bytesize > max_allowed_packet + raise ActiveRecordError, + "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." + elsif previous_packet.nil? + true + else + (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet + end + end + + def max_allowed_packet + @max_allowed_packet ||= begin + bytes_margin = 2 + show_variable("max_allowed_packet") - bytes_margin + end + end + def exec_stmt_and_free(sql, name, binds, cache_stmt: false) if preventing_writes? && write_query?(sql) raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index 8cb6b64fec..d21535a709 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -64,16 +64,6 @@ module ActiveRecord case type when :virtual type = options[:type] - when :text, :blob, :binary - case (size = options[:size])&.to_s - when "tiny", "medium", "long" - sql_type = @conn.native_database_types[type][:name] - type = "#{size}#{sql_type}" - else - raise ArgumentError, <<~MSG unless size.nil? - #{size.inspect} is invalid :size value. Only :tiny, :medium, and :long are allowed. - MSG - end when :primary_key type = :integer options[:limit] ||= 8 diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb index e9484a08de..4018f0815c 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -97,6 +97,30 @@ module ActiveRecord MySQL::SchemaDumper.create(self, options) end + # Maps logical Rails types to MySQL-specific data types. + def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_size(limit, type), unsigned: nil, **) + sql = + case type.to_s + when "integer" + integer_to_sql(limit) + when "text" + type_with_size_to_sql("text", size) + when "blob" + type_with_size_to_sql("blob", size) + when "binary" + if (0..0xfff) === limit + "varbinary(#{limit})" + else + type_with_size_to_sql("blob", size) + end + else + super + end + + sql = "#{sql} unsigned" if unsigned && type != :primary_key + sql + end + private CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"] @@ -197,6 +221,40 @@ module ActiveRecord schema, name = nil, schema unless name [schema, name] end + + def type_with_size_to_sql(type, size) + case size&.to_s + when nil, "tiny", "medium", "long" + "#{size}#{type}" + else + raise ArgumentError, + "#{size.inspect} is invalid :size value. Only :tiny, :medium, and :long are allowed." + end + end + + def limit_to_size(limit, type) + case type.to_s + when "text", "blob", "binary" + case limit + when 0..0xff; "tiny" + when nil, 0x100..0xffff; nil + when 0x10000..0xffffff; "medium" + when 0x1000000..0xffffffff; "long" + else raise ActiveRecordError, "No #{type} type has byte size #{limit}" + end + end + end + + def integer_to_sql(limit) + case limit + when 1; "tinyint" + when 2; "smallint" + when 3; "mediumint" + when nil, 4; "int" + when 5..8; "bigint" + else raise ActiveRecordError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead." + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb index 41633872e2..ae7dbd2868 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -164,6 +164,10 @@ module ActiveRecord end private + def build_truncate_statements(*table_names) + "TRUNCATE TABLE #{table_names.map(&method(:quote_table_name)).join(", ")}" + end + # Returns the current ID of a table's sequence. def last_insert_id_result(sequence_name) exec_query("SELECT currval(#{quote(sequence_name)})", "SQL") diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb index 946436f7f9..a38c1325c0 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -548,14 +548,14 @@ module ActiveRecord # The hard limit is 1GB, because of a 32-bit size field, and TOAST. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "No binary type has byte size #{limit}.") + else raise ActiveRecordError, "No binary type has byte size #{limit}. The limit on binary can be at most 1GB - 1byte." end when "text" # PostgreSQL doesn't support limits on text columns. # The hard limit is 1GB, according to section 8.3 in the manual. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "The limit on text can be at most 1GB - 1byte.") + else raise ActiveRecordError, "No text type has byte size #{limit}. The limit on text can be at most 1GB - 1byte." end when "integer" case limit @@ -623,10 +623,10 @@ module ActiveRecord # validate_foreign_key :accounts, name: :special_fk_name # # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. - def validate_foreign_key(from_table, options_or_to_table = {}) + def validate_foreign_key(from_table, to_table = nil, **options) return unless supports_validate_constraints? - fk_name_to_validate = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_validate = foreign_key_for!(from_table, to_table: to_table, **options).name validate_constraint from_table, fk_name_to_validate end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 95beeb4cae..29f764e8f4 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -196,6 +196,17 @@ module ActiveRecord true end + def supports_insert_returning? + true + end + + def supports_insert_on_conflict? + postgresql_version >= 90500 + end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? + def index_algorithms { concurrently: "CONCURRENTLY" } end @@ -236,15 +247,10 @@ module ActiveRecord # @local_tz is initialized as nil to avoid warnings when connect tries to use it @local_tz = nil - @default_timezone = nil - @timestamp_decoder = nil @max_identifier_length = nil configure_connection add_pg_encoders - @statements = StatementPool.new @connection, - self.class.type_cast_config_to_integer(config[:statement_limit]) - add_pg_decoders @type_map = Type::HashLookupTypeMap.new @@ -253,17 +259,6 @@ module ActiveRecord @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true end - # Clears the prepared statements cache. - def clear_cache! - @lock.synchronize do - @statements.clear - end - end - - def truncate(table_name, name = nil) - exec_query "TRUNCATE TABLE #{quote_table_name(table_name)}", name, [] - end - # Is this connection alive and ready for queries? def active? @lock.synchronize do @@ -280,6 +275,8 @@ module ActiveRecord super @connection.reset configure_connection + rescue PG::ConnectionBad + connect end end @@ -350,6 +347,13 @@ module ActiveRecord postgresql_version >= 90400 end + def supports_optimizer_hints? + unless defined?(@has_pg_hint_plan) + @has_pg_hint_plan = extension_available?("pg_hint_plan") + end + @has_pg_hint_plan + end + def supports_lazy_transactions? true end @@ -380,9 +384,12 @@ module ActiveRecord } end + def extension_available?(name) + query_value("SELECT true FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") + end + def extension_enabled?(name) - res = exec_query("SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", "SCHEMA") - res.cast_values.first + query_value("SELECT installed_version IS NOT NULL FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") end def extensions @@ -425,6 +432,20 @@ module ActiveRecord index.using == :btree || super end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") + end + + sql << " RETURNING #{insert.returning}" if insert.returning + sql + end + private def check_version if postgresql_version < 90300 @@ -723,6 +744,8 @@ module ActiveRecord def connect @connection = PG.connect(@connection_parameters) configure_connection + add_pg_encoders + add_pg_decoders end # Configures the encoding, verbosity, schema search path, and time zone of the connection. @@ -803,6 +826,10 @@ module ActiveRecord Arel::Visitors::PostgreSQL.new(self) end + def build_statement_pool + StatementPool.new(@connection, self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + def can_perform_case_insensitive_comparison_for?(column) @case_insensitive_cache ||= {} @case_insensitive_cache[column.sql_type] ||= begin @@ -846,6 +873,9 @@ module ActiveRecord end def add_pg_decoders + @default_timezone = nil + @timestamp_decoder = nil + coders_by_name = { "int2" => PG::TextDecoder::Integer, "int4" => PG::TextDecoder::Integer, diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index c10765f42d..07453b4403 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -13,6 +13,7 @@ module ActiveRecord @columns_hash = {} @primary_keys = {} @data_sources = {} + @indexes = {} end def initialize_dup(other) @@ -21,22 +22,25 @@ module ActiveRecord @columns_hash = @columns_hash.dup @primary_keys = @primary_keys.dup @data_sources = @data_sources.dup + @indexes = @indexes.dup end def encode_with(coder) - coder["columns"] = @columns + coder["columns"] = @columns coder["columns_hash"] = @columns_hash coder["primary_keys"] = @primary_keys coder["data_sources"] = @data_sources - coder["version"] = connection.migration_context.current_version + coder["indexes"] = @indexes + coder["version"] = connection.migration_context.current_version end def init_with(coder) - @columns = coder["columns"] + @columns = coder["columns"] @columns_hash = coder["columns_hash"] @primary_keys = coder["primary_keys"] @data_sources = coder["data_sources"] - @version = coder["version"] + @indexes = coder["indexes"] || {} + @version = coder["version"] end def primary_keys(table_name) @@ -57,6 +61,7 @@ module ActiveRecord primary_keys(table_name) columns(table_name) columns_hash(table_name) + indexes(table_name) end end @@ -82,17 +87,22 @@ module ActiveRecord @columns_hash.key?(table_name) end + def indexes(table_name) + @indexes[table_name] ||= connection.indexes(table_name) + end + # Clears out internal caches def clear! @columns.clear @columns_hash.clear @primary_keys.clear @data_sources.clear + @indexes.clear @version = nil end def size - [@columns, @columns_hash, @primary_keys, @data_sources].map(&:size).inject :+ + [@columns, @columns_hash, @primary_keys, @data_sources].sum(&:size) end # Clear out internal caches for the data source +name+. @@ -101,20 +111,21 @@ module ActiveRecord @columns_hash.delete name @primary_keys.delete name @data_sources.delete name + @indexes.delete name end def marshal_dump # if we get current version during initialization, it happens stack over flow. @version = connection.migration_context.current_version - [@version, @columns, @columns_hash, @primary_keys, @data_sources] + [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes] end def marshal_load(array) - @version, @columns, @columns_hash, @primary_keys, @data_sources = array + @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes = array + @indexes = @indexes || {} end private - def prepare_data_sources connection.data_sources.each { |source| @data_sources[source] = true } end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb new file mode 100644 index 0000000000..84dcae49b9 --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module DatabaseStatements + private + def execute_batch(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute_batch(sql) + end + end + end + + def build_fixture_statements(fixture_set) + fixture_set.flat_map do |table_name, fixtures| + next if fixtures.empty? + fixtures.map { |fixture| build_fixture_sql([fixture], table_name) } + end.compact + end + + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "DELETE FROM #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb index bd649451d6..e64e995e1a 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -72,7 +72,7 @@ module ActiveRecord table = strip_table_name_prefix_and_suffix(table) fk_to_table = strip_table_name_prefix_and_suffix(fk.to_table) fk_to_table == table && options.all? { |k, v| fk.options[k].to_s == v.to_s } - end || raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table}") + end || raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") foreign_keys.delete(fkey) alter_table(from_table, foreign_keys) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 9f28fdf749..ff23a525b9 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -4,6 +4,7 @@ require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/sqlite3/explain_pretty_printer" require "active_record/connection_adapters/sqlite3/quoting" +require "active_record/connection_adapters/sqlite3/database_statements" require "active_record/connection_adapters/sqlite3/schema_creation" require "active_record/connection_adapters/sqlite3/schema_definitions" require "active_record/connection_adapters/sqlite3/schema_dumper" @@ -36,8 +37,6 @@ module ActiveRecord config.merge(results_as_hash: true) ) - db.busy_timeout(ConnectionAdapters::SQLite3Adapter.type_cast_config_to_integer(config[:timeout])) if config[:timeout] - ConnectionAdapters::SQLite3Adapter.new(db, logger, nil, config) rescue Errno::ENOENT => error if error.message.include?("No such file or directory") @@ -60,6 +59,7 @@ module ActiveRecord include SQLite3::Quoting include SQLite3::SchemaStatements + include SQLite3::DatabaseStatements NATIVE_DATABASE_TYPES = { primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL", @@ -95,9 +95,6 @@ module ActiveRecord def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @active = true - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) configure_connection end @@ -137,23 +134,29 @@ module ActiveRecord true end + def supports_insert_on_conflict? + sqlite_version >= "3.24.0" + end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? + def active? - @active + !@connection.closed? + end + + def reconnect! + super + connect if @connection.closed? end # Disconnects from the database if already connected. Otherwise, this # method does nothing. def disconnect! super - @active = false @connection.close rescue nil end - # Clears the prepared statements cache. - def clear_cache! - @statements.clear - end - def supports_index_sort_order? true end @@ -381,16 +384,17 @@ module ActiveRecord end end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - disable_referential_integrity do - transaction(requires_new: true) do - tables_to_delete.each { |table| delete "DELETE FROM #{quote_table_name(table)}", "Fixture Delete" } + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" - fixture_set.each do |table_name, rows| - rows.each { |row| insert_fixture(row, table_name) } - end - end + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") end + + sql end private @@ -589,7 +593,21 @@ module ActiveRecord Arel::Visitors::SQLite.new(self) end + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + + def connect + @connection = ::SQLite3::Database.new( + @config[:database].to_s, + @config.merge(results_as_hash: true) + ) + configure_connection + end + def configure_connection + @connection.busy_timeout(self.class.type_cast_config_to_integer(@config[:timeout])) if @config[:timeout] + execute("PRAGMA foreign_keys = ON", "SCHEMA") end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index c67980173f..eb4b48bc37 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -161,7 +161,7 @@ module ActiveRecord return super if block_given? || primary_key.nil? || scope_attributes? || - columns_hash.include?(inheritance_column) + columns_hash.key?(inheritance_column) && !base_class? id = ids.first @@ -182,7 +182,8 @@ module ActiveRecord end def find_by(*args) # :nodoc: - return super if scope_attributes? || reflect_on_all_aggregations.any? + return super if scope_attributes? || reflect_on_all_aggregations.any? || + columns_hash.key?(inheritance_column) && !base_class? hash = args.first diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index a6c702cbbc..4656045fe5 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -106,7 +106,7 @@ module ActiveRecord build_db_config = configs.each_pair.flat_map do |env_name, config| walk_configs(env_name.to_s, "primary", config) - end.compact + end.flatten.compact if url = ENV["DATABASE_URL"] build_url_config(url, build_db_config) diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb index e6dba66a08..8077630aeb 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -31,7 +31,9 @@ module ActiveRecord # as well. With the above example: # # Conversation.active + # Conversation.not_active # Conversation.archived + # Conversation.not_archived # # Of course, you can also query them directly if the scopes don't fit your # needs: @@ -196,9 +198,13 @@ module ActiveRecord define_method("#{value_method_name}!") { update!(attr => value) } # scope :active, -> { where(status: 0) } + # scope :not_active, -> { where.not(status: 0) } if enum_scopes != false klass.send(:detect_enum_conflict!, name, value_method_name, true) klass.scope value_method_name, -> { where(attr => value) } + + klass.send(:detect_enum_conflict!, name, "not_#{value_method_name}", true) + klass.scope "not_#{value_method_name}", -> { where.not(attr => value) } end end end diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 48c0e8bbcc..60cf9818c1 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -68,7 +68,7 @@ module ActiveRecord # Raised by {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] and # {ActiveRecord::Base.create!}[rdoc-ref:Persistence::ClassMethods#create!] - # methods when a record is invalid and can not be saved. + # methods when a record is invalid and cannot be saved. class RecordNotSaved < ActiveRecordError attr_reader :record diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 16dff9c2b0..f77bc2e3c1 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -10,7 +10,7 @@ module ActiveRecord MAJOR = 6 MINOR = 0 TINY = 0 - PRE = "beta1" + PRE = "beta3" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb new file mode 100644 index 0000000000..98c98d61cd --- /dev/null +++ b/activerecord/lib/active_record/insert_all.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +module ActiveRecord + class InsertAll + attr_reader :model, :connection, :inserts, :keys + attr_reader :on_duplicate, :returning, :unique_by + + def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil) + raise ArgumentError, "Empty list of attributes passed" if inserts.blank? + + @model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s).to_set + @on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by + + @returning = (connection.supports_insert_returning? ? primary_keys : false) if @returning.nil? + @returning = false if @returning == [] + + @on_duplicate = :skip if @on_duplicate == :update && updatable_columns.empty? + + ensure_valid_options_for_connection! + end + + def execute + connection.exec_query to_sql, "Bulk Insert" + end + + def updatable_columns + keys - readonly_columns - unique_by_columns + end + + def skip_duplicates? + on_duplicate == :skip + end + + def update_duplicates? + on_duplicate == :update + end + + def map_key_with_value + inserts.map do |attributes| + attributes = attributes.stringify_keys + verify_attributes(attributes) + + keys.map do |key| + yield key, attributes[key] + end + end + end + + private + def ensure_valid_options_for_connection! + if returning && !connection.supports_insert_returning? + 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? + raise ArgumentError, "#{connection.class} does not support skipping duplicates" + end + + if on_duplicate == :update && !connection.supports_insert_on_duplicate_update? + raise ArgumentError, "#{connection.class} does not support upsert" + end + + if unique_by && !connection.supports_insert_conflict_target? + raise ArgumentError, "#{connection.class} does not support :unique_by" + end + end + + def to_sql + connection.build_insert_sql(ActiveRecord::InsertAll::Builder.new(self)) + end + + def readonly_columns + primary_keys + model.readonly_attributes.to_a + end + + def unique_by_columns + unique_by ? unique_by.fetch(:columns).map(&:to_s) : [] + end + + def primary_keys + Array.wrap(model.primary_key) + end + + def verify_attributes(attributes) + if keys != attributes.keys.to_set + raise ArgumentError, "All objects being inserted must have the same keys" + end + end + + + class Builder + attr_reader :model + + delegate :skip_duplicates?, :update_duplicates?, to: :insert_all + + def initialize(insert_all) + @insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection + end + + def into + "INTO #{model.quoted_table_name}(#{columns_list})" + end + + def values_list + types = extract_types_from_columns_on(model.table_name, keys: insert_all.keys) + + values_list = insert_all.map_key_with_value do |key, value| + bind = Relation::QueryAttribute.new(key, value, types[key]) + connection.with_yaml_fallback(bind.value_for_database) + end + + Arel::InsertManager.new.create_values_list(values_list).to_sql + end + + def returning + quote_columns(insert_all.returning).join(",") if insert_all.returning + end + + def conflict_target + return unless conflict_columns + sql = +"(#{quote_columns(conflict_columns).join(',')})" + sql << " WHERE #{where}" if where + sql + end + + def updatable_columns + quote_columns(insert_all.updatable_columns) + end + + private + attr_reader :connection, :insert_all + + def columns_list + quote_columns(insert_all.keys).join(",") + end + + def extract_types_from_columns_on(table_name, keys:) + columns = connection.schema_cache.columns_hash(table_name) + + unknown_column = (keys - columns.keys).first + raise UnknownAttributeError.new(model.new, unknown_column) if unknown_column + + keys.map { |key| [ key, connection.lookup_cast_type_from_column(columns[key]) ] }.to_h + end + + def quote_columns(columns) + columns.map(&connection.method(:quote_column_name)) + end + + def conflict_columns + @conflict_columns ||= begin + conflict_columns = insert_all.unique_by.fetch(:columns) if insert_all.unique_by + conflict_columns ||= Array.wrap(model.primary_key) if update_duplicates? + conflict_columns + end + end + + def where + insert_all.unique_by && insert_all.unique_by[:where] + end + end + end +end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index c20274420f..997b7f763a 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -308,7 +308,7 @@ module ActiveRecord # named +column_name+ from the table called +table_name+. # * <tt>remove_columns(table_name, *column_names)</tt>: Removes the given # columns from the table definition. - # * <tt>remove_foreign_key(from_table, options_or_to_table)</tt>: Removes the + # * <tt>remove_foreign_key(from_table, to_table = nil, **options)</tt>: Removes the # given foreign key from the table called +table_name+. # * <tt>remove_index(table_name, column: column_names)</tt>: Removes the index # specified by +column_names+. diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index 82f5121d94..8e7f596076 100644 --- a/activerecord/lib/active_record/migration/command_recorder.rb +++ b/activerecord/lib/active_record/migration/command_recorder.rb @@ -231,24 +231,15 @@ module ActiveRecord end def invert_remove_foreign_key(args) - from_table, options_or_to_table, options_or_nil = args + options = args.extract_options! + from_table, to_table = args - to_table = if options_or_to_table.is_a?(Hash) - options_or_to_table[:to_table] - else - options_or_to_table - end - - remove_options = if options_or_to_table.is_a?(Hash) - options_or_to_table.except(:to_table) - else - options_or_nil - end + to_table ||= options.delete(:to_table) raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? reversed_args = [from_table, to_table] - reversed_args << remove_options if remove_options.present? + reversed_args << options unless options.empty? [:add_foreign_key, reversed_args] end diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 10148d0dca..0c31f0f57e 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_record/insert_all" + module ActiveRecord # = Active Record \Persistence module Persistence @@ -55,6 +57,207 @@ module ActiveRecord end end + # Inserts a single record into the database. This method constructs a single SQL INSERT + # statement and sends it straight to the database. It does not instantiate the involved + # models and it does not trigger Active Record callbacks or validations. However, values + # passed to #insert will still go through Active Record's normal type casting and + # serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all</tt> for documentation. + def insert(attributes, returning: nil, unique_by: nil) + insert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Inserts multiple records into the database. This method constructs a single SQL INSERT + # statement and sends it straight to the database. It does not instantiate the involved + # models and it does not trigger Active Record callbacks or validations. However, values + # passed to #insert_all will still go through Active Record's normal type casting and + # serialization. + # + # The +attributes+ parameter is an Array of Hashes. These Hashes describe the + # attributes on the objects that are to be created. All of the Hashes must have + # same keys. + # + # Records that would violate a unique constraint on the table are skipped. + # + # Returns an <tt>ActiveRecord::Result</tt>. The contents of the result depend on the + # value of <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (Postgres-only) An array of attributes that should be returned for all successfully + # inserted records. For databases that support <tt>INSERT ... RETURNING</tt>, this will default + # to returning the primary keys of the successfully inserted records. Pass + # <tt>returning: %w[ id name ]</tt> to return the id and name of every successfully inserted + # record or pass <tt>returning: false</tt> to omit the clause. + # + # [:unique_by] + # (Postgres and SQLite only) In a table with more than one unique constraint or index, + # new records may be considered duplicates according to different criteria. By default, + # new rows will be skipped if they violate _any_ unique constraint or index. By defining + # <tt>:unique_by</tt>, you can skip rows that would create duplicates according to the given + # constraint but raise <tt>ActiveRecord::RecordNotUnique</tt> if rows violate other constraints. + # + # (For example, maybe you assume a client will try to import the same ISBNs more than + # once and want to silently ignore the duplicate records, but you don't except any of + # your code to attempt to create two rows with the same primary key and would appreciate + # an exception report in that scenario.) + # + # Indexes can be identified by an array of columns: + # + # unique_by: { columns: %w[ isbn ] } + # + # Partial indexes can be identified by an array of columns and a <tt>:where</tt> condition: + # + # unique_by: { columns: %w[ isbn ], where: "published_on IS NOT NULL" } + # + # ==== Example + # + # # Insert multiple records and skip duplicates + # # ('Eloquent Ruby' will be skipped because its id is duplicate) + # Book.insert_all([ + # { id: 1, title: 'Rework', author: 'David' }, + # { id: 1, title: 'Eloquent Ruby', author: 'Russ' } + # ]) + # + def insert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by).execute + end + + # Inserts a single record into the database. This method constructs a single SQL INSERT + # statement and sends it straight to the database. It does not instantiate the involved + # models and it does not trigger Active Record callbacks or validations. However, values + # passed to #insert! will still go through Active Record's normal type casting and + # serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all!</tt> for documentation. + def insert!(attributes, returning: nil) + insert_all!([ attributes ], returning: returning) + end + + # Inserts multiple records into the database. This method constructs a single SQL INSERT + # statement and sends it straight to the database. It does not instantiate the involved + # models and it does not trigger Active Record callbacks or validations. However, values + # passed to #insert_all! will still go through Active Record's normal type casting and + # serialization. + # + # The +attributes+ parameter is an Array of Hashes. These Hashes describe the + # attributes on the objects that are to be created. All of the Hashes must have + # same keys. + # + # #insert_all! will raise <tt>ActiveRecord::RecordNotUnique</tt> if any of the records being + # inserts would violate a unique constraint on the table. In that case, no records + # would be inserted. + # + # To skip duplicate records, see <tt>ActiveRecord::Persistence#insert_all</tt>. + # To replace them, see <tt>ActiveRecord::Persistence#upsert_all</tt>. + # + # Returns an <tt>ActiveRecord::Result</tt>. The contents of the result depend on the + # value of <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (Postgres-only) An array of attributes that should be returned for all successfully + # inserted records. For databases that support <tt>INSERT ... RETURNING</tt>, this will default + # to returning the primary keys of the successfully inserted records. Pass + # <tt>returning: %w[ id name ]</tt> to return the id and name of every successfully inserted + # record or pass <tt>returning: false</tt> to omit the clause. + # + # ==== Examples + # + # # Insert multiple records + # Book.insert_all!([ + # { title: 'Rework', author: 'David' }, + # { title: 'Eloquent Ruby', author: 'Russ' } + # ]) + # + # # Raises ActiveRecord::RecordNotUnique because 'Eloquent Ruby' + # # does not have a unique ID + # Book.insert_all!([ + # { id: 1, title: 'Rework', author: 'David' }, + # { id: 1, title: 'Eloquent Ruby', author: 'Russ' } + # ]) + # + def insert_all!(attributes, returning: nil) + InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute + end + + # Upserts (updates or inserts) a single record into the database. This method constructs + # a single SQL INSERT statement and sends it straight to the database. It does not + # instantiate the involved models and it does not trigger Active Record callbacks or + # validations. However, values passed to #upsert will still go through Active Record's + # normal type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#upsert_all</tt> for documentation. + def upsert(attributes, returning: nil, unique_by: nil) + upsert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Upserts (updates or inserts) multiple records into the database. This method constructs + # a single SQL INSERT statement and sends it straight to the database. It does not + # instantiate the involved models and it does not trigger Active Record callbacks or + # validations. However, values passed to #upsert_all will still go through Active Record's + # normal type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. These Hashes describe the + # attributes on the objects that are to be created. All of the Hashes must have + # same keys. + # + # Returns an <tt>ActiveRecord::Result</tt>. The contents of the result depend on the + # value of <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (Postgres-only) An array of attributes that should be returned for all successfully + # inserted records. For databases that support <tt>INSERT ... RETURNING</tt>, this will default + # to returning the primary keys of the successfully inserted records. Pass + # <tt>returning: %w[ id name ]</tt> to return the id and name of every successfully inserted + # record or pass <tt>returning: false</tt> to omit the clause. + # + # [:unique_by] + # (Postgres and SQLite only) In a table with more than one unique constraint or index, + # new records may be considered duplicates according to different criteria. For MySQL, + # an upsert will take place if a new record violates _any_ unique constraint. For + # Postgres and SQLite, new rows will replace existing rows when the new row has the + # same primary key as the existing row. In case of SQLite, an upsert operation causes + # an insert to behave as an update or a no-op if the insert would violate + # a uniqueness constraint. By defining <tt>:unique_by</tt>, you can supply + # a different unique constraint for matching new records to existing ones than the + # primary key. + # + # (For example, if you have a unique index on the ISBN column and use that as + # the <tt>:unique_by</tt>, a new record with the same ISBN as an existing record + # will replace the existing record but a new record with the same primary key + # as an existing record will raise <tt>ActiveRecord::RecordNotUnique</tt>.) + # + # Indexes can be identified by an array of columns: + # + # unique_by: { columns: %w[ isbn ] } + # + # Partial indexes can be identified by an array of columns and a <tt>:where</tt> condition: + # + # unique_by: { columns: %w[ isbn ], where: "published_on IS NOT NULL" } + # + # ==== Examples + # + # # Given a unique index on <tt>books.isbn</tt> and the following record: + # Book.create!(title: 'Rework', author: 'David', isbn: '1') + # + # # Insert multiple records, allowing new records with the same ISBN + # # as an existing record to overwrite the existing record. + # # ('Eloquent Ruby' will overwrite 'Rework' because its ISBN is duplicate) + # Book.upsert_all([ + # { title: 'Eloquent Ruby', author: 'Russ', isbn: '1' }, + # { title: 'Clean Code', author: 'Robert', isbn: '2' } + # ], unique_by: { columns: %w[ isbn ] }) + # + def upsert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :update, returning: returning, unique_by: unique_by).execute + end + # Given an attributes hash, +instantiate+ returns a new instance of # the appropriate class. Accepts only keys as strings. # diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index b3eeb60571..86021f0a80 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -2,18 +2,23 @@ module ActiveRecord module Querying - delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, to: :all - delegate :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, to: :all - delegate :first_or_create, :first_or_create!, :first_or_initialize, to: :all - delegate :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, to: :all - delegate :find_by, :find_by!, to: :all - delegate :destroy_all, :delete_all, :update_all, :destroy_by, :delete_by, to: :all - delegate :find_each, :find_in_batches, :in_batches, to: :all - delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, - :having, :create_with, :distinct, :references, :none, :unscope, :merge, to: :all - delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all - delegate :pluck, :pick, :ids, to: :all + QUERYING_METHODS = [ + :find, :find_by, :find_by!, :take, :take!, :first, :first!, :last, :last!, + :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, + :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, + :exists?, :any?, :many?, :none?, :one?, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, + :create_or_find_by, :create_or_find_by!, + :destroy_all, :delete_all, :update_all, :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, + :having, :create_with, :distinct, :references, :none, :unscope, :optimizer_hints, :merge, :except, :only, + :count, :average, :minimum, :maximum, :sum, :calculate, + :pluck, :pick, :ids + ].freeze # :nodoc: + delegate(*QUERYING_METHODS, to: :all) # Executes a custom SQL query against your database and returns all the results. The results will # be returned as an array, with the requested columns encapsulated as attributes of the model you call diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 8de06e8466..f021a8f6c4 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -66,6 +66,11 @@ db_namespace = namespace :db do end end + # desc "Truncates tables of each database for current environment" + task truncate_all: [:load_config, :check_protected_environments] do + ActiveRecord::Tasks::DatabaseTasks.truncate_all + end + # desc "Empty the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:purge:all to purge all databases in the config). Without RAILS_ENV it defaults to purging the development and test databases." task purge: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.purge_current @@ -223,6 +228,11 @@ db_namespace = namespace :db do ActiveRecord::Tasks::DatabaseTasks.load_seed end + namespace :seed do + desc "Truncates tables of each database for current environment and loads the seeds" + task replant: [:load_config, :truncate_all, :seed] + end + namespace :fixtures do desc "Loads fixtures into the current environment's database. Load specific fixtures using FIXTURES=x,y. Load from subdirectory in test/fixtures using FIXTURES_DIR=z. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." task load: :load_config do diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index f6b21eaa3a..6d954a2b2e 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -5,7 +5,7 @@ module ActiveRecord class Relation MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_outer_joins, :references, - :extending, :unscope] + :extending, :unscope, :optimizer_hints] SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :skip_query_cache] @@ -197,6 +197,10 @@ module ActiveRecord # if a DELETE between those two statements is run by another client. But for most applications, # that's a significantly less likely condition to hit. # * It relies on exception handling to handle control flow, which may be marginally slower. + # * The primary key may auto-increment on each create, even if it fails. This can accelerate + # the problem of running out of integers, if the underlying table is still stuck on a primary + # key of type int (note: All Rails apps since 5.1+ have defaulted to bigint, which is not liable + # to this problem). # # This method will return a record if all given attributes are covered by unique constraints # (unless the INSERT -> DELETE -> SELECT race condition is triggered), but if creation was attempted @@ -338,6 +342,8 @@ module ActiveRecord # trigger Active Record callbacks or validations. However, values passed to #update_all will still go through # Active Record's normal type casting and serialization. # + # Note: As Active Record callbacks are not triggered, this method will not automatically update +updated_at+/+updated_on+ columns. + # # ==== Parameters # # * +updates+ - A string, array, or hash representing the SET part of an SQL statement. @@ -372,6 +378,12 @@ module ActiveRecord stmt.wheres = arel.constraints if updates.is_a?(Hash) + if klass.locking_enabled? && + !updates.key?(klass.locking_column) && + !updates.key?(klass.locking_column.to_sym) + attr = arel_attribute(klass.locking_column) + updates[attr.name] = _increment_attribute(attr) + end stmt.set _substitute_values(updates) else stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name)) @@ -394,10 +406,7 @@ module ActiveRecord updates = {} counters.each do |counter_name, value| attr = arel_attribute(counter_name) - bind = predicate_builder.build_bind_attribute(attr.name, value.abs) - expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attr), 0) - expr = value < 0 ? expr - bind : expr + bind - updates[counter_name] = expr.expr + updates[attr.name] = _increment_attribute(attr, value) end if touch @@ -409,10 +418,10 @@ module ActiveRecord update_all updates end - # Touches all records in the current relation without instantiating records first with the updated_at/on attributes + # Touches all records in the current relation without instantiating records first with the +updated_at+/+updated_on+ attributes # set to the current time or the time specified. # This method can be passed attribute names and an optional time argument. - # If attribute names are passed, they are updated along with updated_at/on attributes. + # If attribute names are passed, they are updated along with +updated_at+/+updated_on+ attributes. # If no time argument is passed, the current time is used as default. # # === Examples @@ -433,12 +442,7 @@ module ActiveRecord # Person.where(name: 'David').touch_all # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670' WHERE \"people\".\"name\" = 'David'" def touch_all(*names, time: nil) - if klass.locking_enabled? - names << { time: time } - update_counters(klass.locking_column => 1, touch: names) - else - update_all klass.touch_attributes_with_time(*names, time: time) - end + update_all klass.touch_attributes_with_time(*names, time: time) end # Destroys the records by instantiating each @@ -709,6 +713,13 @@ module ActiveRecord end end + def _increment_attribute(attribute, value = 1) + bind = predicate_builder.build_bind_attribute(attribute.name, value.abs) + expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attribute), 0) + expr = value < 0 ? expr - bind : expr + bind + expr.expr + end + def exec_queries(&block) skip_query_cache_if_necessary do @records = diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index bdd3c540bb..4f9ddf302e 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -221,7 +221,6 @@ module ActiveRecord end private - def has_include?(column_name) eager_loading? || (includes_values.present? && column_name && column_name != :all) end @@ -236,10 +235,12 @@ module ActiveRecord if operation == "count" column_name ||= select_for_count if column_name == :all - if distinct && (group_values.any? || select_values.empty? && order_values.empty?) + if !distinct + distinct = distinct_select?(select_for_count) if group_values.empty? + elsif group_values.any? || select_values.empty? && order_values.empty? column_name = primary_key end - elsif column_name.is_a?(::String) && /\bDISTINCT[\s(]/i.match?(column_name) + elsif distinct_select?(column_name) distinct = nil end end @@ -251,6 +252,10 @@ module ActiveRecord end end + def distinct_select?(column_name) + column_name.is_a?(::String) && /\bDISTINCT[\s(]/i.match?(column_name) + end + def aggregate_column(column_name) return column_name if Arel::Expressions === column_name diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index f7c3b3783f..7a53a9d1c7 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "mutex_m" + module ActiveRecord module Delegation # :nodoc: module DelegateCache # :nodoc: @@ -31,6 +33,10 @@ module ActiveRecord super end + def generate_relation_method(method) + generated_relation_methods.generate_method(method) + end + protected def include_relation_methods(delegate) superclass.include_relation_methods(delegate) unless base_class? @@ -39,27 +45,32 @@ module ActiveRecord private def generated_relation_methods - @generated_relation_methods ||= Module.new.tap do |mod| - mod_name = "GeneratedRelationMethods" - const_set mod_name, mod - private_constant mod_name - end + @generated_relation_methods ||= GeneratedRelationMethods.new end + end + + class GeneratedRelationMethods < Module # :nodoc: + include Mutex_m + + def generate_method(method) + synchronize do + return if method_defined?(method) - def generate_relation_method(method) if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - generated_relation_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method}(*args, &block) scoping { klass.#{method}(*args, &block) } end RUBY else - generated_relation_methods.define_method(method) do |*args, &block| + define_method(method) do |*args, &block| scoping { klass.public_send(method, *args, &block) } end end end + end end + private_constant :GeneratedRelationMethods extend ActiveSupport::Concern @@ -78,39 +89,17 @@ module ActiveRecord module ClassSpecificRelation # :nodoc: extend ActiveSupport::Concern - included do - @delegation_mutex = Mutex.new - end - module ClassMethods # :nodoc: def name superclass.name end - - def delegate_to_scoped_klass(method) - @delegation_mutex.synchronize do - return if method_defined?(method) - - if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method}(*args, &block) - scoping { @klass.#{method}(*args, &block) } - end - RUBY - else - define_method method do |*args, &block| - scoping { @klass.public_send(method, *args, &block) } - end - end - end - end end private def method_missing(method, *args, &block) if @klass.respond_to?(method) - self.class.delegate_to_scoped_klass(method) + @klass.generate_relation_method(method) scoping { @klass.public_send(method, *args, &block) } else super diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 91cfc4e849..e2efd4aa0d 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -7,8 +7,8 @@ module ActiveRecord ONE_AS_ONE = "1 AS one" # Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]). - # If one or more records can not be found for the requested ids, then RecordNotFound will be raised. If the primary key - # is an integer, find by id coerces its arguments using +to_i+. + # If one or more records cannot be found for the requested ids, then ActiveRecord::RecordNotFound will be raised. + # If the primary key is an integer, find by id coerces its arguments by using +to_i+. # # Person.find(1) # returns the object for ID = 1 # Person.find("1") # returns the object for ID = 1 diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 5563dfb6c9..05ea0850d3 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -240,6 +240,27 @@ module ActiveRecord self end + # Allows you to change a previously set select statement. + # + # Post.select(:title, :body) + # # SELECT `posts`.`title`, `posts`.`body` FROM `posts` + # + # Post.select(:title, :body).reselect(:created_at) + # # SELECT `posts`.`created_at` FROM `posts` + # + # This is short-hand for <tt>unscope(:select).select(fields)</tt>. + # Note that we're unscoping the entire select statement. + def reselect(*args) + check_if_method_has_arguments!(:reselect, args) + spawn.reselect!(*args) + end + + # Same as #reselect but operates on relation in-place instead of copying. + def reselect!(*args) # :nodoc: + self.select_values = args + self + end + # Allows to specify a group attribute: # # User.group(:name) @@ -329,7 +350,7 @@ module ActiveRecord VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock, :limit, :offset, :joins, :left_outer_joins, - :includes, :from, :readonly, :having]) + :includes, :from, :readonly, :having, :optimizer_hints]) # Removes an unwanted relation that is already defined on a chain of relations. # This is useful when passing around chains of relations and would like to @@ -880,6 +901,29 @@ module ActiveRecord self end + # Specify optimizer hints to be used in the SELECT statement. + # + # Example (for MySQL): + # + # Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)") + # # SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics` + # + # Example (for PostgreSQL with pg_hint_plan): + # + # Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)") + # # SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics" + def optimizer_hints(*args) + check_if_method_has_arguments!(:optimizer_hints, args) + spawn.optimizer_hints!(*args) + end + + def optimizer_hints!(*args) # :nodoc: + args.flatten! + + self.optimizer_hints_values += args + self + end + # Reverse the existing order clause on the relation. # # User.order('name ASC').reverse_order # generated SQL has 'ORDER BY name DESC' @@ -956,6 +1000,7 @@ module ActiveRecord build_select(arel) + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? arel.distinct(distinct_value) arel.from(build_from) unless from_clause.empty? arel.lock(lock_value) if lock_value @@ -1068,14 +1113,19 @@ module ActiveRecord def arel_column(field) field = klass.attribute_alias(field) if klass.attribute_alias?(field) + from = from_clause.name || from_clause.value - if klass.columns_hash.key?(field) && !from_clause.value + if klass.columns_hash.key?(field) && (!from || table_name_matches?(from)) arel_attribute(field) else yield end end + def table_name_matches?(from) + /(?:\A|(?<!FROM)\s)(?:\b#{table.name}\b|#{connection.quote_table_name(table.name)})(?!\.)/i.match?(from.to_s) + end + def reverse_sql_order(order_query) if order_query.empty? return [arel_attribute(primary_key).desc] if primary_key @@ -1091,7 +1141,7 @@ module ActiveRecord o.reverse when String if does_not_support_reverse?(o) - raise IrreversibleOrderError, "Order #{o.inspect} can not be reversed automatically" + raise IrreversibleOrderError, "Order #{o.inspect} cannot be reversed automatically" end o.split(",").map! do |s| s.strip! diff --git a/activerecord/lib/active_record/sanitization.rb b/activerecord/lib/active_record/sanitization.rb index e6197752bc..750766714d 100644 --- a/activerecord/lib/active_record/sanitization.rb +++ b/activerecord/lib/active_record/sanitization.rb @@ -165,10 +165,11 @@ module ActiveRecord def quote_bound_value(value, c = connection) if value.respond_to?(:map) && !value.acts_like?(:string) - if value.respond_to?(:empty?) && value.empty? + quoted = value.map { |v| c.quote(v) } + if quoted.empty? c.quote(nil) else - value.map { |v| c.quote(v) }.join(",") + quoted.join(",") end else c.quote(value) diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 8c612df27a..87bcfd5181 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -86,8 +86,8 @@ module ActiveRecord # # Should return a scope, you can call 'super' here etc. # end # end - def default_scope(scope = nil) # :doc: - scope = Proc.new if block_given? + def default_scope(scope = nil, &block) # :doc: + scope = block if block_given? if scope.is_a?(Relation) || !scope.respond_to?(:call) raise ArgumentError, @@ -100,7 +100,7 @@ module ActiveRecord self.default_scopes += [scope] end - def build_default_scope(base_rel = nil) + def build_default_scope(relation = relation()) return if abstract_class? if default_scope_override.nil? @@ -111,15 +111,14 @@ module ActiveRecord # The user has defined their own default scope method, so call that evaluate_default_scope do if scope = default_scope - (base_rel ||= relation).merge!(scope) + relation.merge!(scope) end end elsif default_scopes.any? - base_rel ||= relation evaluate_default_scope do - default_scopes.inject(base_rel) do |default_scope, scope| + default_scopes.inject(relation) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge!(base_rel.instance_exec(&scope)) + default_scope.instance_exec(&scope) || default_scope end end end diff --git a/activerecord/lib/active_record/statement_cache.rb b/activerecord/lib/active_record/statement_cache.rb index 95984e7ada..93bce15230 100644 --- a/activerecord/lib/active_record/statement_cache.rb +++ b/activerecord/lib/active_record/statement_cache.rb @@ -113,8 +113,8 @@ module ActiveRecord end end - def self.create(connection, block = Proc.new) - relation = block.call Params.new + def self.create(connection, callable = nil, &block) + relation = (callable || block).call Params.new query_builder, binds = connection.cacheable_query(self, relation.arel) bind_map = BindMap.new(binds) new(query_builder, bind_map, relation.klass) diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 27e401a756..155d2b0b98 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -142,7 +142,7 @@ module ActiveRecord end def for_each - databases = Rails.application.config.database_configuration + databases = Rails.application.config.load_database_yaml database_configs = ActiveRecord::DatabaseConfigurations.new(databases).configs_for(env_name: Rails.env) # if this is a single database application we don't want tasks for each primary database @@ -182,6 +182,25 @@ module ActiveRecord } end + def truncate_tables(configuration) + 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 + ] + + ActiveRecord::Base.connection.truncate_tables(*table_names) + end + end + private :truncate_tables + + def truncate_all(environment = env) + ActiveRecord::Base.configurations.configs_for(env_name: environment).each do |db_config| + truncate_tables db_config.config + end + end + def migrate check_target_version diff --git a/activerecord/lib/active_record/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb index 88ae62c681..2c3a2fb797 100644 --- a/activerecord/lib/active_record/validations/uniqueness.rb +++ b/activerecord/lib/active_record/validations/uniqueness.rb @@ -25,7 +25,7 @@ module ActiveRecord if finder_class.primary_key relation = relation.where.not(finder_class.primary_key => record.id_in_database) else - raise UnknownPrimaryKey.new(finder_class, "Can not validate uniqueness for persisted record without primary key.") + raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.") end end relation = scope_relation(record, relation) @@ -60,10 +60,8 @@ module ActiveRecord comparison = relation.bind_attribute(attribute, value) do |attr, bind| return relation.none! if bind.unboundable? - if bind.nil? - attr.eq(bind) - elsif !options.key?(:case_sensitive) - klass.connection.default_uniqueness_comparison(attr, bind) + if !options.key?(:case_sensitive) || bind.nil? + klass.connection.default_uniqueness_comparison(attr, bind, klass) elsif options[:case_sensitive] klass.connection.case_sensitive_comparison(attr, bind) else diff --git a/activerecord/lib/arel/insert_manager.rb b/activerecord/lib/arel/insert_manager.rb index c90fc33a48..cb31e3060b 100644 --- a/activerecord/lib/arel/insert_manager.rb +++ b/activerecord/lib/arel/insert_manager.rb @@ -33,13 +33,13 @@ module Arel # :nodoc: all @ast.columns << column values << value end - @ast.values = create_values values, @ast.columns + @ast.values = create_values(values) end self end - def create_values(values, columns) - Nodes::Values.new values, columns + def create_values(values) + Nodes::ValuesList.new([values]) end def create_values_list(rows) diff --git a/activerecord/lib/arel/nodes.rb b/activerecord/lib/arel/nodes.rb index 5af0e532e2..2f6dd9bc45 100644 --- a/activerecord/lib/arel/nodes.rb +++ b/activerecord/lib/arel/nodes.rb @@ -45,7 +45,6 @@ require "arel/nodes/and" require "arel/nodes/function" require "arel/nodes/count" require "arel/nodes/extract" -require "arel/nodes/values" require "arel/nodes/values_list" require "arel/nodes/named_function" diff --git a/activerecord/lib/arel/nodes/select_core.rb b/activerecord/lib/arel/nodes/select_core.rb index 73461ff683..6585f9b3ec 100644 --- a/activerecord/lib/arel/nodes/select_core.rb +++ b/activerecord/lib/arel/nodes/select_core.rb @@ -4,12 +4,13 @@ module Arel # :nodoc: all module Nodes class SelectCore < Arel::Nodes::Node attr_accessor :projections, :wheres, :groups, :windows - attr_accessor :havings, :source, :set_quantifier + attr_accessor :havings, :source, :set_quantifier, :optimizer_hints def initialize super() @source = JoinSource.new nil + @optimizer_hints = nil # https://ronsavage.github.io/SQL/sql-92.bnf.html#set%20quantifier @set_quantifier = nil @projections = [] @@ -42,7 +43,7 @@ module Arel # :nodoc: all def hash [ - @source, @set_quantifier, @projections, + @source, @set_quantifier, @projections, @optimizer_hints, @wheres, @groups, @havings, @windows ].hash end @@ -51,6 +52,7 @@ module Arel # :nodoc: all self.class == other.class && self.source == other.source && self.set_quantifier == other.set_quantifier && + self.optimizer_hints == other.optimizer_hints && self.projections == other.projections && self.wheres == other.wheres && self.groups == other.groups && diff --git a/activerecord/lib/arel/nodes/unary.rb b/activerecord/lib/arel/nodes/unary.rb index 00639304e4..6d1ac36b0e 100644 --- a/activerecord/lib/arel/nodes/unary.rb +++ b/activerecord/lib/arel/nodes/unary.rb @@ -35,6 +35,7 @@ module Arel # :nodoc: all Not Offset On + OptimizerHints Ordering RollUp }.each do |name| diff --git a/activerecord/lib/arel/nodes/values.rb b/activerecord/lib/arel/nodes/values.rb deleted file mode 100644 index 650248dc04..0000000000 --- a/activerecord/lib/arel/nodes/values.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Arel # :nodoc: all - module Nodes - class Values < Arel::Nodes::Binary - alias :expressions :left - alias :expressions= :left= - alias :columns :right - alias :columns= :right= - - def initialize(exprs, columns = []) - super - end - end - end -end diff --git a/activerecord/lib/arel/nodes/values_list.rb b/activerecord/lib/arel/nodes/values_list.rb index 27109848e4..1a9d9ebf01 100644 --- a/activerecord/lib/arel/nodes/values_list.rb +++ b/activerecord/lib/arel/nodes/values_list.rb @@ -2,23 +2,8 @@ module Arel # :nodoc: all module Nodes - class ValuesList < Node - attr_reader :rows - - def initialize(rows) - @rows = rows - super() - end - - def hash - @rows.hash - end - - def eql?(other) - self.class == other.class && - self.rows == other.rows - end - alias :== :eql? + class ValuesList < Unary + alias :rows :expr end end end diff --git a/activerecord/lib/arel/select_manager.rb b/activerecord/lib/arel/select_manager.rb index a2b2838a3d..32286b67f4 100644 --- a/activerecord/lib/arel/select_manager.rb +++ b/activerecord/lib/arel/select_manager.rb @@ -146,6 +146,13 @@ module Arel # :nodoc: all @ctx.projections = projections end + def optimizer_hints(*hints) + unless hints.empty? + @ctx.optimizer_hints = Arel::Nodes::OptimizerHints.new(hints) + end + self + end + def distinct(value = true) if value @ctx.set_quantifier = Arel::Nodes::Distinct.new @@ -237,18 +244,6 @@ module Arel # :nodoc: all @ctx.source end - class Row < Struct.new(:data) # :nodoc: - def id - data["id"] - end - - def method_missing(name, *args) - name = name.to_s - return data[name] if data.key?(name) - super - end - 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 92d309453c..109afb7402 100644 --- a/activerecord/lib/arel/visitors/depth_first.rb +++ b/activerecord/lib/arel/visitors/depth_first.rb @@ -35,6 +35,8 @@ module Arel # :nodoc: all alias :visit_Arel_Nodes_Ascending :unary alias :visit_Arel_Nodes_Descending :unary alias :visit_Arel_Nodes_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_OptimizerHints :unary + alias :visit_Arel_Nodes_ValuesList :unary def function(o) visit o.expressions @@ -102,7 +104,6 @@ module Arel # :nodoc: all alias :visit_Arel_Nodes_Regexp :binary alias :visit_Arel_Nodes_RightOuterJoin :binary alias :visit_Arel_Nodes_TableAlias :binary - alias :visit_Arel_Nodes_Values :binary alias :visit_Arel_Nodes_When :binary def visit_Arel_Nodes_StringJoin(o) diff --git a/activerecord/lib/arel/visitors/dot.rb b/activerecord/lib/arel/visitors/dot.rb index 6389c875cb..37803ce0c0 100644 --- a/activerecord/lib/arel/visitors/dot.rb +++ b/activerecord/lib/arel/visitors/dot.rb @@ -46,8 +46,8 @@ module Arel # :nodoc: all visit_edge o, "distinct" end - def visit_Arel_Nodes_Values(o) - visit_edge o, "expressions" + def visit_Arel_Nodes_ValuesList(o) + visit_edge o, "rows" end def visit_Arel_Nodes_StringJoin(o) @@ -82,6 +82,7 @@ module Arel # :nodoc: all alias :visit_Arel_Nodes_Offset :unary alias :visit_Arel_Nodes_On :unary alias :visit_Arel_Nodes_UnqualifiedColumn :unary + alias :visit_Arel_Nodes_OptimizerHints :unary alias :visit_Arel_Nodes_Preceding :unary alias :visit_Arel_Nodes_Following :unary alias :visit_Arel_Nodes_Rows :unary diff --git a/activerecord/lib/arel/visitors/ibm_db.rb b/activerecord/lib/arel/visitors/ibm_db.rb index 73166054da..0ffc0725f7 100644 --- a/activerecord/lib/arel/visitors/ibm_db.rb +++ b/activerecord/lib/arel/visitors/ibm_db.rb @@ -4,6 +4,14 @@ module Arel # :nodoc: all module Visitors class IBM_DB < Arel::Visitors::ToSql private + def visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + collector << "/* <OPTGUIDELINES>#{sanitize_as_sql_comment(o).join}</OPTGUIDELINES> */" + end def visit_Arel_Nodes_Limit(o, collector) collector << "FETCH FIRST " @@ -16,6 +24,10 @@ module Arel # :nodoc: all collector = visit [o.left, o.right, 0, 1], collector collector << ")" end + + def collect_optimizer_hints(o, collector) + collector + end end end end diff --git a/activerecord/lib/arel/visitors/informix.rb b/activerecord/lib/arel/visitors/informix.rb index 208fa15aef..cd43be8858 100644 --- a/activerecord/lib/arel/visitors/informix.rb +++ b/activerecord/lib/arel/visitors/informix.rb @@ -42,10 +42,15 @@ module Arel # :nodoc: all collector end + def visit_Arel_Nodes_OptimizerHints(o, collector) + collector << "/*+ #{sanitize_as_sql_comment(o).join(", ")} */" + end + def visit_Arel_Nodes_Offset(o, collector) collector << "SKIP " visit o.expr, collector end + def visit_Arel_Nodes_Limit(o, collector) collector << "FIRST " visit o.expr, collector diff --git a/activerecord/lib/arel/visitors/mssql.rb b/activerecord/lib/arel/visitors/mssql.rb index fdd864b40d..85815baca2 100644 --- a/activerecord/lib/arel/visitors/mssql.rb +++ b/activerecord/lib/arel/visitors/mssql.rb @@ -76,6 +76,15 @@ module Arel # :nodoc: all end end + def visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + collector << "OPTION (#{sanitize_as_sql_comment(o).join(", ")})" + end + def get_offset_limit_clause(o) first_row = o.offset ? o.offset.expr.to_i + 1 : 1 last_row = o.limit ? o.limit.expr.to_i - 1 + first_row : nil @@ -103,6 +112,10 @@ module Arel # :nodoc: all end end + def collect_optimizer_hints(o, collector) + collector + end + def determine_order_by(orders, x) if orders.any? orders diff --git a/activerecord/lib/arel/visitors/to_sql.rb b/activerecord/lib/arel/visitors/to_sql.rb index d0dec63860..583f920290 100644 --- a/activerecord/lib/arel/visitors/to_sql.rb +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -159,7 +159,7 @@ module Arel # :nodoc: all when Nodes::SqlLiteral, Nodes::BindParam collector = visit(value, collector) else - collector << quote(value) + collector << quote(value).to_s end collector << COMMA unless k == row_len end @@ -169,25 +169,6 @@ module Arel # :nodoc: all collector end - def visit_Arel_Nodes_Values(o, collector) - collector << "VALUES (" - - len = o.expressions.length - 1 - o.expressions.each_with_index { |value, i| - case value - when Nodes::SqlLiteral, Nodes::BindParam - collector = visit value, collector - else - collector << quote(value).to_s - end - unless i == len - collector << COMMA - end - } - - collector << ")" - end - def visit_Arel_Nodes_SelectStatement(o, collector) if o.with collector = visit o.with, collector @@ -219,6 +200,7 @@ module Arel # :nodoc: all def visit_Arel_Nodes_SelectCore(o, collector) collector << "SELECT" + collector = collect_optimizer_hints(o, collector) collector = maybe_visit o.set_quantifier, collector collect_nodes_for o.projections, collector, SPACE @@ -236,6 +218,10 @@ module Arel # :nodoc: all collector end + def visit_Arel_Nodes_OptimizerHints(o, collector) + collector << "/*+ #{sanitize_as_sql_comment(o).join(" ")} */" + end + def collect_nodes_for(nodes, collector, spacer, connector = COMMA) unless nodes.empty? collector << spacer @@ -799,6 +785,16 @@ module Arel # :nodoc: all @connection.quote_column_name(name) end + def sanitize_as_sql_comment(o) + o.expr.map { |v| + v.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "") + } + end + + def collect_optimizer_hints(o, collector) + maybe_visit o.optimizer_hints, collector + end + def maybe_visit(thing, collector) return collector unless thing collector << " " |