-
-
Save sgoedecke/49eb8a5347ff2e6376eecbdb39c4866a to your computer and use it in GitHub Desktop.
# frozen_string_literal: true | |
class PeopleHaveMiddleNames < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "middle_name", :string | |
end | |
def self.down | |
remove_column "people", "middle_name" | |
end | |
end |
# frozen_string_literal: true | |
class CreateArticles < ActiveRecord::Migration::Current | |
def self.up | |
end | |
def self.down | |
end | |
end |
# frozen_string_literal: true | |
# coding: ISO-8859-15 | |
class CurrenciesHaveSymbols < ActiveRecord::Migration::Current | |
def self.up | |
# We use € for default currency symbol | |
add_column "currencies", "symbol", :string, default: "€" | |
end | |
def self.down | |
remove_column "currencies", "symbol" | |
end | |
end |
# frozen_string_literal: true | |
class GiveMeBigNumbers < ActiveRecord::Migration::Current | |
def self.up | |
create_table :big_numbers do |table| | |
table.column :bank_balance, :decimal, precision: 10, scale: 2 | |
table.column :big_bank_balance, :decimal, precision: 15, scale: 2 | |
table.column :world_population, :decimal, precision: 20 | |
table.column :my_house_population, :decimal, precision: 2 | |
table.column :value_of_e, :decimal | |
end | |
end | |
def self.down | |
drop_table :big_numbers | |
end | |
end |
# frozen_string_literal: true | |
class PeopleHaveHobbies < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "hobbies", :string | |
end | |
def self.down | |
remove_column "people", "hobbies" | |
end | |
end |
# frozen_string_literal: true | |
class PeopleHaveLastNames < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "last_name", :string | |
end | |
def self.down | |
remove_column "people", "last_name" | |
end | |
end |
# frozen_string_literal: true | |
class Unscoped < ActiveRecord::Migration::Current | |
def self.change | |
create_table "unscoped" | |
end | |
end |
# frozen_string_literal: true | |
class ValidPeopleHaveLastNames < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "last_name", :string | |
end | |
def self.down | |
remove_column "people", "last_name" | |
end | |
end |
# frozen_string_literal: true | |
class WeNeedThings < ActiveRecord::Migration::Current | |
def self.up | |
create_table("things") do |t| | |
t.column :content, :text | |
end | |
end | |
def self.down | |
drop_table "things" | |
end | |
end |
# frozen_string_literal: true | |
class CreateArticles < ActiveRecord::Migration::Current | |
def self.up | |
end | |
def self.down | |
end | |
end |
# frozen_string_literal: true | |
class PeopleHaveHobbies < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "hobbies", :text | |
end | |
def self.down | |
remove_column "people", "hobbies" | |
end | |
end |
# frozen_string_literal: true | |
class CreateComments < ActiveRecord::Migration::Current | |
def self.up | |
end | |
def self.down | |
end | |
end |
# frozen_string_literal: true | |
class PeopleHaveDescriptions < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "description", :text | |
end | |
def self.down | |
remove_column "people", "description" | |
end | |
end |
# frozen_string_literal: true | |
class ValidWithTimestampsPeopleHaveLastNames < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "last_name", :string | |
end | |
def self.down | |
remove_column "people", "last_name" | |
end | |
end |
# frozen_string_literal: true | |
class ValidWithTimestampsWeNeedReminders < ActiveRecord::Migration::Current | |
def self.up | |
create_table("reminders") do |t| | |
t.column :content, :text | |
t.column :remind_at, :datetime | |
end | |
end | |
def self.down | |
drop_table "reminders" | |
end | |
end |
# frozen_string_literal: true | |
class ValidWithTimestampsInnocentJointable < ActiveRecord::Migration::Current | |
def self.up | |
create_table("people_reminders", id: false) do |t| | |
t.column :reminder_id, :integer | |
t.column :person_id, :integer | |
end | |
end | |
def self.down | |
drop_table "people_reminders" | |
end | |
end |
# frozen_string_literal: true | |
class MigrationVersionCheck < ActiveRecord::Migration::Current | |
def self.up | |
raise "incorrect migration version" unless version == 20131219224947 | |
end | |
def self.down | |
end | |
end |
class CreateActiveStorageTables < ActiveRecord::Migration[5.2] | |
def change | |
# Use Active Record's configured type for primary and foreign keys | |
primary_key_type, foreign_key_type = primary_and_foreign_key_types | |
create_table :active_storage_blobs, id: primary_key_type do |t| | |
t.string :key, null: false | |
t.string :filename, null: false | |
t.string :content_type | |
t.text :metadata | |
t.string :service_name, null: false | |
t.bigint :byte_size, null: false | |
t.string :checksum | |
if connection.supports_datetime_with_precision? | |
t.datetime :created_at, precision: 6, null: false | |
else | |
t.datetime :created_at, null: false | |
end | |
t.index [ :key ], unique: true | |
end | |
create_table :active_storage_attachments, id: primary_key_type do |t| | |
t.string :name, null: false | |
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type | |
t.references :blob, null: false, type: foreign_key_type | |
if connection.supports_datetime_with_precision? | |
t.datetime :created_at, precision: 6, null: false | |
else | |
t.datetime :created_at, null: false | |
end | |
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true | |
t.foreign_key :active_storage_blobs, column: :blob_id | |
end | |
create_table :active_storage_variant_records, id: primary_key_type do |t| | |
t.belongs_to :blob, null: false, index: false, type: foreign_key_type | |
t.string :variation_digest, null: false | |
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true | |
t.foreign_key :active_storage_blobs, column: :blob_id | |
end | |
end | |
private | |
def primary_and_foreign_key_types | |
config = Rails.configuration.generators | |
setting = config.options[config.orm][:primary_key_type] | |
primary_key_type = setting || :primary_key | |
foreign_key_type = setting || :bigint | |
[primary_key_type, foreign_key_type] | |
end | |
end |
class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] | |
def up | |
unless column_exists?(:active_storage_blobs, :service_name) | |
add_column :active_storage_blobs, :service_name, :string | |
if configured_service = ActiveStorage::Blob.service.name | |
ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) | |
end | |
change_column :active_storage_blobs, :service_name, :string, null: false | |
end | |
end | |
def down | |
remove_column :active_storage_blobs, :service_name | |
end | |
end |
class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0] | |
def change | |
# Use Active Record's configured type for primary key | |
create_table :active_storage_variant_records, id: primary_key_type, if_not_exists: true do |t| | |
t.belongs_to :blob, null: false, index: false, type: blobs_primary_key_type | |
t.string :variation_digest, null: false | |
t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true | |
t.foreign_key :active_storage_blobs, column: :blob_id | |
end | |
end | |
private | |
def primary_key_type | |
config = Rails.configuration.generators | |
config.options[config.orm][:primary_key_type] || :primary_key | |
end | |
def blobs_primary_key_type | |
pkey_name = connection.primary_key(:active_storage_blobs) | |
pkey_column = connection.columns(:active_storage_blobs).find { |c| c.name == pkey_name } | |
pkey_column.bigint? ? :bigint : pkey_column.type | |
end | |
end |
# frozen_string_literal: true | |
class AddPeopleDescription < ActiveRecord::Migration::Current | |
add_column :people, :description, :string | |
end |
# frozen_string_literal: true | |
class AddPeopleNumberOfLegs < ActiveRecord::Migration::Current | |
add_column :people, :number_of_legs, :integer | |
end |
class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] | |
def change | |
change_column_null(:active_storage_blobs, :checksum, true) | |
end | |
end |
# frozen_string_literal: true | |
class AddPeopleHobby < ActiveRecord::Migration::Current | |
add_column :people, :hobby, :string | |
end |
# frozen_string_literal: true | |
class AddPeopleLastName < ActiveRecord::Migration::Current | |
add_column :people, :last_name, :string | |
end |
# frozen_string_literal: true | |
class CreateComments < ActiveRecord::Migration::Current | |
def self.up | |
end | |
def self.down | |
end | |
end |
# frozen_string_literal: true | |
class MysqlOnly < ActiveRecord::Migration::Current | |
def self.change | |
create_table "mysql_only" | |
end | |
end |
# frozen_string_literal: true | |
class PeopleHaveDescriptions < ActiveRecord::Migration::Current | |
def self.up | |
add_column "people", "description", :text | |
end | |
def self.down | |
remove_column "people", "description" | |
end | |
end |
# frozen_string_literal: true | |
class RenameThings < ActiveRecord::Migration::Current | |
def self.up | |
rename_table "things", "awesome_things" | |
end | |
def self.down | |
rename_table "awesome_things", "things" | |
end | |
end |
# frozen_string_literal: true | |
class WeNeedReminders < ActiveRecord::Migration::Current | |
def self.up | |
create_table("reminders") do |t| | |
t.column :content, :text | |
t.column :remind_at, :datetime | |
end | |
end | |
def self.down | |
drop_table "reminders" | |
end | |
end |
# frozen_string_literal: true | |
class InnocentJointable < ActiveRecord::Migration::Current | |
def self.up | |
create_table("people_reminders", id: false) do |t| | |
t.column :reminder_id, :integer | |
t.column :person_id, :integer | |
end | |
end | |
def self.down | |
drop_table "people_reminders" | |
end | |
end |
# frozen_string_literal: true | |
class WeNeedReminders < ActiveRecord::Migration::Current | |
def self.up | |
create_table("reminders") do |t| | |
t.column :content, :text | |
t.column :remind_at, :datetime | |
end | |
end | |
def self.down | |
drop_table "reminders" | |
end | |
end |
# frozen_string_literal: true | |
class InnocentJointable < ActiveRecord::Migration::Current | |
def self.up | |
create_table("people_reminders", id: false) do |t| | |
t.column :reminder_id, :integer | |
t.column :person_id, :integer | |
end | |
end | |
def self.down | |
drop_table "people_reminders" | |
end | |
end |
# frozen_string_literal: true | |
class AddExpressions < ActiveRecord::Migration::Current | |
def self.up | |
create_table("expressions") do |t| | |
t.column :expression, :string | |
end | |
end | |
def self.down | |
drop_table "expressions" | |
end | |
end |
# frozen_string_literal: true | |
class AbortBeforeEnqueueJob < ActiveJob::Base | |
MyError = Class.new(StandardError) | |
before_enqueue :throw_or_raise | |
after_enqueue { self.flag = "after_enqueue" } | |
before_perform { throw(:abort) } | |
after_perform { self.flag = "after_perform" } | |
attr_accessor :flag | |
def perform | |
raise "This should never be called" | |
end | |
def throw_or_raise | |
if (arguments.first || :abort) == :abort | |
throw(:abort) | |
else | |
raise(MyError) | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Validations | |
class AbsenceValidator < ActiveModel::Validations::AbsenceValidator # :nodoc: | |
def validate_each(record, attribute, association_or_value) | |
if record.class._reflect_on_association(attribute) | |
association_or_value = Array.wrap(association_or_value).reject(&:marked_for_destruction?) | |
end | |
super | |
end | |
end | |
module ClassMethods | |
# Validates that the specified attributes are not present (as defined by | |
# Object#present?). If the attribute is an association, the associated object | |
# is considered absent if it was marked for destruction. | |
# | |
# See ActiveModel::Validations::HelperMethods.validates_absence_of for more information. | |
def validates_absence_of(*attr_names) | |
validates_with AbsenceValidator, _merge_attributes(attr_names) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/face" | |
require "models/interest" | |
require "models/human" | |
require "models/topic" | |
class AbsenceValidationTest < ActiveRecord::TestCase | |
def test_non_association | |
boy_klass = Class.new(Human) do | |
def self.name; "Boy" end | |
validates_absence_of :name | |
end | |
assert_predicate boy_klass.new, :valid? | |
assert_not_predicate boy_klass.new(name: "Alex"), :valid? | |
end | |
def test_has_one_marked_for_destruction | |
boy_klass = Class.new(Human) do | |
def self.name; "Boy" end | |
validates_absence_of :face | |
end | |
boy = boy_klass.new(face: Face.new) | |
assert_not boy.valid?, "should not be valid if has_one association is present" | |
assert_equal 1, boy.errors[:face].size, "should only add one error" | |
boy.face.mark_for_destruction | |
assert boy.valid?, "should be valid if association is marked for destruction" | |
end | |
def test_has_many_marked_for_destruction | |
boy_klass = Class.new(Human) do | |
def self.name; "Boy" end | |
validates_absence_of :interests | |
end | |
boy = boy_klass.new | |
boy.interests << [i1 = Interest.new, i2 = Interest.new] | |
assert_not boy.valid?, "should not be valid if has_many association is present" | |
i1.mark_for_destruction | |
assert_not boy.valid?, "should not be valid if has_many association is present" | |
i2.mark_for_destruction | |
assert_predicate boy, :valid? | |
end | |
def test_does_not_call_to_a_on_associations | |
boy_klass = Class.new(Human) do | |
def self.name; "Boy" end | |
validates_absence_of :face | |
end | |
face_with_to_a = Face.new | |
def face_with_to_a.to_a; ["(/)", '(\)']; end | |
assert_nothing_raised { boy_klass.new(face: face_with_to_a).valid? } | |
end | |
def test_validates_absence_of_virtual_attribute_on_model | |
repair_validations(Interest) do | |
Interest.attr_accessor(:token) | |
Interest.validates_absence_of(:token) | |
interest = Interest.create!(topic: "Thought Leadering") | |
assert_predicate interest, :valid? | |
interest.token = "tl" | |
assert_predicate interest, :invalid? | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "set" | |
require "active_record/connection_adapters/sql_type_metadata" | |
require "active_record/connection_adapters/abstract/schema_dumper" | |
require "active_record/connection_adapters/abstract/schema_creation" | |
require "active_support/concurrency/load_interlock_aware_monitor" | |
require "arel/collectors/bind" | |
require "arel/collectors/composite" | |
require "arel/collectors/sql_string" | |
require "arel/collectors/substitute_binds" | |
module ActiveRecord | |
module ConnectionAdapters # :nodoc: | |
# Active Record supports multiple database systems. AbstractAdapter and | |
# related classes form the abstraction layer which makes this possible. | |
# An AbstractAdapter represents a connection to a database, and provides an | |
# abstract interface for database-specific functionality such as establishing | |
# a connection, escaping values, building the right SQL fragments for +:offset+ | |
# and +:limit+ options, etc. | |
# | |
# All the concrete database adapters follow the interface laid down in this class. | |
# {ActiveRecord::Base.connection}[rdoc-ref:ConnectionHandling#connection] returns an AbstractAdapter object, which | |
# you can use. | |
# | |
# Most of the methods in the adapter are useful during migrations. Most | |
# notably, the instance methods provided by SchemaStatements are very useful. | |
class AbstractAdapter | |
ADAPTER_NAME = "Abstract" | |
include ActiveSupport::Callbacks | |
define_callbacks :checkout, :checkin | |
include Quoting, DatabaseStatements, SchemaStatements | |
include DatabaseLimits | |
include QueryCache | |
include Savepoints | |
SIMPLE_INT = /\A\d+\z/ | |
COMMENT_REGEX = %r{(?:--.*\n)*|/\*(?:[^*]|\*[^/])*\*/}m | |
attr_accessor :pool | |
attr_reader :visitor, :owner, :logger, :lock | |
alias :in_use? :owner | |
set_callback :checkin, :after, :enable_lazy_transactions! | |
def self.type_cast_config_to_integer(config) | |
if config.is_a?(Integer) | |
config | |
elsif SIMPLE_INT.match?(config) | |
config.to_i | |
else | |
config | |
end | |
end | |
def self.type_cast_config_to_boolean(config) | |
if config == "false" | |
false | |
else | |
config | |
end | |
end | |
def self.validate_default_timezone(config) | |
case config | |
when nil | |
when "utc", "local" | |
config.to_sym | |
else | |
raise ArgumentError, "default_timezone must be either 'utc' or 'local'" | |
end | |
end | |
DEFAULT_READ_QUERY = [:begin, :commit, :explain, :release, :rollback, :savepoint, :select, :with] # :nodoc: | |
private_constant :DEFAULT_READ_QUERY | |
def self.build_read_query_regexp(*parts) # :nodoc: | |
parts += DEFAULT_READ_QUERY | |
parts = parts.map { |part| /#{part}/i } | |
/\A(?:[(\s]|#{COMMENT_REGEX})*#{Regexp.union(*parts)}/ | |
end | |
def self.quoted_column_names # :nodoc: | |
@quoted_column_names ||= {} | |
end | |
def self.quoted_table_names # :nodoc: | |
@quoted_table_names ||= {} | |
end | |
def initialize(connection, logger = nil, config = {}) # :nodoc: | |
super() | |
@raw_connection = connection | |
@owner = nil | |
@instrumenter = ActiveSupport::Notifications.instrumenter | |
@logger = logger | |
@config = config | |
@pool = ActiveRecord::ConnectionAdapters::NullPool.new | |
@idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) | |
@visitor = arel_visitor | |
@statements = build_statement_pool | |
@lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new | |
@prepared_statements = self.class.type_cast_config_to_boolean( | |
config.fetch(:prepared_statements, true) | |
) | |
@advisory_locks_enabled = self.class.type_cast_config_to_boolean( | |
config.fetch(:advisory_locks, true) | |
) | |
@default_timezone = self.class.validate_default_timezone(config[:default_timezone]) | |
@raw_connection_dirty = false | |
configure_connection | |
end | |
EXCEPTION_NEVER = { Exception => :never }.freeze # :nodoc: | |
EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze # :nodoc: | |
private_constant :EXCEPTION_NEVER, :EXCEPTION_IMMEDIATE | |
def with_instrumenter(instrumenter, &block) # :nodoc: | |
Thread.handle_interrupt(EXCEPTION_NEVER) do | |
previous_instrumenter = @instrumenter | |
@instrumenter = instrumenter | |
Thread.handle_interrupt(EXCEPTION_IMMEDIATE, &block) | |
ensure | |
@instrumenter = previous_instrumenter | |
end | |
end | |
def check_if_write_query(sql) # :nodoc: | |
if preventing_writes? && write_query?(sql) | |
raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" | |
end | |
end | |
def replica? | |
@config[:replica] || false | |
end | |
def use_metadata_table? | |
@config.fetch(:use_metadata_table, true) | |
end | |
def default_timezone | |
@default_timezone || ActiveRecord.default_timezone | |
end | |
# Determines whether writes are currently being prevented. | |
# | |
# Returns true if the connection is a replica. | |
# | |
# If the application is using legacy handling, returns | |
# true if +connection_handler.prevent_writes+ is set. | |
# | |
# If the application is using the new connection handling | |
# will return true based on +current_preventing_writes+. | |
def preventing_writes? | |
return true if replica? | |
return ActiveRecord::Base.connection_handler.prevent_writes if ActiveRecord.legacy_connection_handling | |
return false if connection_class.nil? | |
connection_class.current_preventing_writes | |
end | |
def migrations_paths # :nodoc: | |
@config[:migrations_paths] || Migrator.migrations_paths | |
end | |
def migration_context # :nodoc: | |
MigrationContext.new(migrations_paths, schema_migration) | |
end | |
def schema_migration # :nodoc: | |
@schema_migration ||= begin | |
conn = self | |
spec_name = conn.pool.pool_config.connection_specification_name | |
return ActiveRecord::SchemaMigration if spec_name == "ActiveRecord::Base" | |
schema_migration_name = "#{spec_name}::SchemaMigration" | |
Class.new(ActiveRecord::SchemaMigration) do | |
define_singleton_method(:name) { schema_migration_name } | |
define_singleton_method(:to_s) { schema_migration_name } | |
self.connection_specification_name = spec_name | |
end | |
end | |
end | |
def prepared_statements? | |
@prepared_statements && !prepared_statements_disabled_cache.include?(object_id) | |
end | |
alias :prepared_statements :prepared_statements? | |
def prepared_statements_disabled_cache # :nodoc: | |
ActiveSupport::IsolatedExecutionState[:active_record_prepared_statements_disabled_cache] ||= Set.new | |
end | |
class Version | |
include Comparable | |
attr_reader :full_version_string | |
def initialize(version_string, full_version_string = nil) | |
@version = version_string.split(".").map(&:to_i) | |
@full_version_string = full_version_string | |
end | |
def <=>(version_string) | |
@version <=> version_string.split(".").map(&:to_i) | |
end | |
def to_s | |
@version.join(".") | |
end | |
end | |
def valid_type?(type) # :nodoc: | |
!native_database_types[type].nil? | |
end | |
# this method must only be called while holding connection pool's mutex | |
def lease | |
if in_use? | |
msg = +"Cannot lease connection, " | |
if @owner == ActiveSupport::IsolatedExecutionState.context | |
msg << "it is already leased by the current thread." | |
else | |
msg << "it is already in use by a different thread: #{@owner}. " \ | |
"Current thread: #{ActiveSupport::IsolatedExecutionState.context}." | |
end | |
raise ActiveRecordError, msg | |
end | |
@owner = ActiveSupport::IsolatedExecutionState.context | |
end | |
def connection_class # :nodoc: | |
@pool.connection_class | |
end | |
# The role (e.g. +:writing+) for the current connection. In a | |
# non-multi role application, +:writing+ is returned. | |
def role | |
@pool.role | |
end | |
# The shard (e.g. +:default+) for the current connection. In | |
# a non-sharded application, +:default+ is returned. | |
def shard | |
@pool.shard | |
end | |
def schema_cache | |
@pool.get_schema_cache(self) | |
end | |
def schema_cache=(cache) | |
cache.connection = self | |
@pool.set_schema_cache(cache) | |
end | |
# this method must only be called while holding connection pool's mutex | |
def expire | |
if in_use? | |
if @owner != ActiveSupport::IsolatedExecutionState.context | |
raise ActiveRecordError, "Cannot expire connection, " \ | |
"it is owned by a different thread: #{@owner}. " \ | |
"Current thread: #{ActiveSupport::IsolatedExecutionState.context}." | |
end | |
@idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) | |
@owner = nil | |
else | |
raise ActiveRecordError, "Cannot expire connection, it is not currently leased." | |
end | |
end | |
# this method must only be called while holding connection pool's mutex (and a desire for segfaults) | |
def steal! # :nodoc: | |
if in_use? | |
if @owner != ActiveSupport::IsolatedExecutionState.context | |
pool.send :remove_connection_from_thread_cache, self, @owner | |
@owner = ActiveSupport::IsolatedExecutionState.context | |
end | |
else | |
raise ActiveRecordError, "Cannot steal connection, it is not currently leased." | |
end | |
end | |
# Seconds since this connection was returned to the pool | |
def seconds_idle # :nodoc: | |
return 0 if in_use? | |
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @idle_since | |
end | |
def unprepared_statement | |
cache = prepared_statements_disabled_cache.add?(object_id) if @prepared_statements | |
yield | |
ensure | |
cache&.delete(object_id) | |
end | |
# Returns the human-readable name of the adapter. Use mixed case - one | |
# can always use downcase if needed. | |
def adapter_name | |
self.class::ADAPTER_NAME | |
end | |
# Does the database for this adapter exist? | |
def self.database_exists?(config) | |
raise NotImplementedError | |
end | |
# Does this adapter support DDL rollbacks in transactions? That is, would | |
# CREATE TABLE or ALTER TABLE get rolled back by a transaction? | |
def supports_ddl_transactions? | |
false | |
end | |
def supports_bulk_alter? | |
false | |
end | |
# Does this adapter support savepoints? | |
def supports_savepoints? | |
false | |
end | |
# Do TransactionRollbackErrors on savepoints affect the parent | |
# transaction? | |
def savepoint_errors_invalidate_transactions? | |
false | |
end | |
def supports_restart_db_transaction? | |
false | |
end | |
# Does this adapter support application-enforced advisory locking? | |
def supports_advisory_locks? | |
false | |
end | |
# Should primary key values be selected from their corresponding | |
# sequence before the insert statement? If true, next_sequence_value | |
# is called before each insert to set the record's primary key. | |
def prefetch_primary_key?(table_name = nil) | |
false | |
end | |
def supports_partitioned_indexes? | |
false | |
end | |
# Does this adapter support index sort order? | |
def supports_index_sort_order? | |
false | |
end | |
# Does this adapter support partial indices? | |
def supports_partial_index? | |
false | |
end | |
# Does this adapter support expression indices? | |
def supports_expression_index? | |
false | |
end | |
# Does this adapter support explain? | |
def supports_explain? | |
false | |
end | |
# Does this adapter support setting the isolation level for a transaction? | |
def supports_transaction_isolation? | |
false | |
end | |
# Does this adapter support database extensions? | |
def supports_extensions? | |
false | |
end | |
# Does this adapter support creating indexes in the same statement as | |
# creating the table? | |
def supports_indexes_in_create? | |
false | |
end | |
# Does this adapter support creating foreign key constraints? | |
def supports_foreign_keys? | |
false | |
end | |
# Does this adapter support creating invalid constraints? | |
def supports_validate_constraints? | |
false | |
end | |
# Does this adapter support creating deferrable constraints? | |
def supports_deferrable_constraints? | |
false | |
end | |
# Does this adapter support creating check constraints? | |
def supports_check_constraints? | |
false | |
end | |
# Does this adapter support views? | |
def supports_views? | |
false | |
end | |
# Does this adapter support materialized views? | |
def supports_materialized_views? | |
false | |
end | |
# Does this adapter support datetime with precision? | |
def supports_datetime_with_precision? | |
false | |
end | |
# Does this adapter support json data type? | |
def supports_json? | |
false | |
end | |
# Does this adapter support metadata comments on database objects (tables, columns, indexes)? | |
def supports_comments? | |
false | |
end | |
# Can comments for tables, columns, and indexes be specified in create/alter table statements? | |
def supports_comments_in_create? | |
false | |
end | |
# Does this adapter support virtual columns? | |
def supports_virtual_columns? | |
false | |
end | |
# Does this adapter support foreign/external tables? | |
def supports_foreign_tables? | |
false | |
end | |
# Does this adapter support optimizer hints? | |
def supports_optimizer_hints? | |
false | |
end | |
def supports_common_table_expressions? | |
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 | |
def supports_concurrent_connections? | |
true | |
end | |
def async_enabled? # :nodoc: | |
supports_concurrent_connections? && | |
!ActiveRecord.async_query_executor.nil? && !pool.async_executor.nil? | |
end | |
# This is meant to be implemented by the adapters that support extensions | |
def disable_extension(name) | |
end | |
# This is meant to be implemented by the adapters that support extensions | |
def enable_extension(name) | |
end | |
# This is meant to be implemented by the adapters that support custom enum types | |
def create_enum(*) # :nodoc: | |
end | |
def advisory_locks_enabled? # :nodoc: | |
supports_advisory_locks? && @advisory_locks_enabled | |
end | |
# This is meant to be implemented by the adapters that support advisory | |
# locks | |
# | |
# Return true if we got the lock, otherwise false | |
def get_advisory_lock(lock_id) # :nodoc: | |
end | |
# This is meant to be implemented by the adapters that support advisory | |
# locks. | |
# | |
# Return true if we released the lock, otherwise false | |
def release_advisory_lock(lock_id) # :nodoc: | |
end | |
# A list of extensions, to be filled in by adapters that support them. | |
def extensions | |
[] | |
end | |
# A list of index algorithms, to be filled by adapters that support them. | |
def index_algorithms | |
{} | |
end | |
# REFERENTIAL INTEGRITY ==================================== | |
# Override to turn off referential integrity while executing <tt>&block</tt>. | |
def disable_referential_integrity | |
yield | |
end | |
# Override to check all foreign key constraints in a database. | |
def all_foreign_keys_valid? | |
true | |
end | |
# CONNECTION MANAGEMENT ==================================== | |
# Checks whether the connection to the database is still active. This includes | |
# checking whether the database is actually capable of responding, i.e. whether | |
# the connection isn't stale. | |
def active? | |
end | |
# Disconnects from the database if already connected, and establishes a | |
# new connection with the database. Implementors should call super | |
# immediately after establishing the new connection (and while still | |
# holding @lock). | |
def reconnect!(restore_transactions: false) | |
reset_transaction(restore: restore_transactions) do | |
clear_cache!(new_connection: true) | |
configure_connection | |
end | |
end | |
# Disconnects from the database if already connected. Otherwise, this | |
# method does nothing. | |
def disconnect! | |
clear_cache!(new_connection: true) | |
reset_transaction | |
end | |
# Immediately forget this connection ever existed. Unlike disconnect!, | |
# this will not communicate with the server. | |
# | |
# After calling this method, the behavior of all other methods becomes | |
# undefined. This is called internally just before a forked process gets | |
# rid of a connection that belonged to its parent. | |
def discard! | |
# This should be overridden by concrete adapters. | |
# | |
# Prevent @raw_connection's finalizer from touching the socket, or | |
# otherwise communicating with its server, when it is collected. | |
if schema_cache.connection == self | |
schema_cache.connection = nil | |
end | |
end | |
# Reset the state of this connection, directing the DBMS to clear | |
# transactions and other connection-related server-side state. Usually a | |
# database-dependent operation. | |
# | |
# If a database driver or protocol does not support such a feature, | |
# implementors may alias this to #reconnect!. Otherwise, implementors | |
# should call super immediately after resetting the connection (and while | |
# still holding @lock). | |
def reset! | |
clear_cache!(new_connection: true) | |
reset_transaction | |
configure_connection | |
end | |
# Removes the connection from the pool and disconnect it. | |
def throw_away! | |
pool.remove self | |
disconnect! | |
end | |
# Clear any caching the database adapter may be doing. | |
def clear_cache!(new_connection: false) | |
if @statements | |
@lock.synchronize do | |
if new_connection | |
@statements.reset | |
else | |
@statements.clear | |
end | |
end | |
end | |
end | |
# Returns true if its required to reload the connection between requests for development mode. | |
def requires_reloading? | |
false | |
end | |
# Checks whether the connection to the database is still active (i.e. not stale). | |
# This is done under the hood by calling #active?. If the connection | |
# is no longer active, then this method will reconnect to the database. | |
def verify! | |
reconnect! unless active? | |
end | |
# Provides access to the underlying database driver for this adapter. For | |
# example, this method returns a Mysql2::Client object in case of Mysql2Adapter, | |
# and a PG::Connection object in case of PostgreSQLAdapter. | |
# | |
# This is useful for when you need to call a proprietary method such as | |
# PostgreSQL's lo_* methods. | |
def raw_connection | |
disable_lazy_transactions! | |
@raw_connection_dirty = true | |
@raw_connection | |
end | |
def default_uniqueness_comparison(attribute, value) # :nodoc: | |
attribute.eq(value) | |
end | |
def case_sensitive_comparison(attribute, value) # :nodoc: | |
attribute.eq(value) | |
end | |
def case_insensitive_comparison(attribute, value) # :nodoc: | |
column = column_for_attribute(attribute) | |
if can_perform_case_insensitive_comparison_for?(column) | |
attribute.lower.eq(attribute.relation.lower(value)) | |
else | |
attribute.eq(value) | |
end | |
end | |
def can_perform_case_insensitive_comparison_for?(column) | |
true | |
end | |
private :can_perform_case_insensitive_comparison_for? | |
# Check the connection back in to the connection pool | |
def close | |
pool.checkin self | |
end | |
def default_index_type?(index) # :nodoc: | |
index.using.nil? | |
end | |
# Called by ActiveRecord::InsertAll, | |
# Passed an instance of ActiveRecord::InsertAll::Builder, | |
# This method implements standard bulk inserts for all databases, but | |
# should be overridden by adapters to implement common features with | |
# non-standard syntax like handling duplicates or returning values. | |
def build_insert_sql(insert) # :nodoc: | |
if insert.skip_duplicates? || insert.update_duplicates? | |
raise NotImplementedError, "#{self.class} should define `build_insert_sql` to implement adapter-specific logic for handling duplicates during INSERT" | |
end | |
"INSERT #{insert.into} #{insert.values_list}" | |
end | |
def get_database_version # :nodoc: | |
end | |
def database_version # :nodoc: | |
schema_cache.database_version | |
end | |
def check_version # :nodoc: | |
end | |
# Returns the version identifier of the schema currently available in | |
# the database. This is generally equal to the number of the highest- | |
# numbered migration that has been executed, or 0 if no schema | |
# information is present / the database is empty. | |
def schema_version | |
migration_context.current_version | |
end | |
def field_ordered_value(column, values) # :nodoc: | |
node = Arel::Nodes::Case.new(column) | |
values.each.with_index(1) do |value, order| | |
node.when(value).then(order) | |
end | |
Arel::Nodes::Ascending.new(node.else(values.length + 1)) | |
end | |
class << self | |
def register_class_with_precision(mapping, key, klass, **kwargs) # :nodoc: | |
mapping.register_type(key) do |*args| | |
precision = extract_precision(args.last) | |
klass.new(precision: precision, **kwargs) | |
end | |
end | |
def extended_type_map(default_timezone:) # :nodoc: | |
Type::TypeMap.new(self::TYPE_MAP).tap do |m| | |
register_class_with_precision m, %r(\A[^\(]*time)i, Type::Time, timezone: default_timezone | |
register_class_with_precision m, %r(\A[^\(]*datetime)i, Type::DateTime, timezone: default_timezone | |
m.alias_type %r(\A[^\(]*timestamp)i, "datetime" | |
end | |
end | |
private | |
def initialize_type_map(m) | |
register_class_with_limit m, %r(boolean)i, Type::Boolean | |
register_class_with_limit m, %r(char)i, Type::String | |
register_class_with_limit m, %r(binary)i, Type::Binary | |
register_class_with_limit m, %r(text)i, Type::Text | |
register_class_with_precision m, %r(date)i, Type::Date | |
register_class_with_precision m, %r(time)i, Type::Time | |
register_class_with_precision m, %r(datetime)i, Type::DateTime | |
register_class_with_limit m, %r(float)i, Type::Float | |
register_class_with_limit m, %r(int)i, Type::Integer | |
m.alias_type %r(blob)i, "binary" | |
m.alias_type %r(clob)i, "text" | |
m.alias_type %r(timestamp)i, "datetime" | |
m.alias_type %r(numeric)i, "decimal" | |
m.alias_type %r(number)i, "decimal" | |
m.alias_type %r(double)i, "float" | |
m.register_type %r(^json)i, Type::Json.new | |
m.register_type(%r(decimal)i) do |sql_type| | |
scale = extract_scale(sql_type) | |
precision = extract_precision(sql_type) | |
if scale == 0 | |
# FIXME: Remove this class as well | |
Type::DecimalWithoutScale.new(precision: precision) | |
else | |
Type::Decimal.new(precision: precision, scale: scale) | |
end | |
end | |
end | |
def register_class_with_limit(mapping, key, klass) | |
mapping.register_type(key) do |*args| | |
limit = extract_limit(args.last) | |
klass.new(limit: limit) | |
end | |
end | |
def extract_scale(sql_type) | |
case sql_type | |
when /\((\d+)\)/ then 0 | |
when /\((\d+)(,(\d+))\)/ then $3.to_i | |
end | |
end | |
def extract_precision(sql_type) | |
$1.to_i if sql_type =~ /\((\d+)(,\d+)?\)/ | |
end | |
def extract_limit(sql_type) | |
$1.to_i if sql_type =~ /\((.*)\)/ | |
end | |
end | |
TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) } | |
EXTENDED_TYPE_MAPS = Concurrent::Map.new | |
private | |
def reconnect_can_restore_state? | |
transaction_manager.restorable? && !@raw_connection_dirty | |
end | |
def extended_type_map_key | |
if @default_timezone | |
{ default_timezone: @default_timezone } | |
end | |
end | |
def type_map | |
if key = extended_type_map_key | |
self.class::EXTENDED_TYPE_MAPS.compute_if_absent(key) do | |
self.class.extended_type_map(**key) | |
end | |
else | |
self.class::TYPE_MAP | |
end | |
end | |
def translate_exception_class(e, sql, binds) | |
message = "#{e.class.name}: #{e.message}" | |
exception = translate_exception( | |
e, message: message, sql: sql, binds: binds | |
) | |
exception.set_backtrace e.backtrace | |
exception | |
end | |
def log(sql, name = "SQL", binds = [], type_casted_binds = [], statement_name = nil, async: false, &block) # :doc: | |
@instrumenter.instrument( | |
"sql.active_record", | |
sql: sql, | |
name: name, | |
binds: binds, | |
type_casted_binds: type_casted_binds, | |
statement_name: statement_name, | |
async: async, | |
connection: self) do | |
@lock.synchronize(&block) | |
rescue => e | |
raise translate_exception_class(e, sql, binds) | |
end | |
end | |
def transform_query(sql) | |
ActiveRecord.query_transformers.each do |transformer| | |
sql = transformer.call(sql) | |
end | |
sql | |
end | |
def translate_exception(exception, message:, sql:, binds:) | |
# override in derived class | |
case exception | |
when RuntimeError | |
exception | |
else | |
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds) | |
end | |
end | |
def without_prepared_statement?(binds) | |
!prepared_statements || binds.empty? | |
end | |
def column_for(table_name, column_name) | |
column_name = column_name.to_s | |
columns(table_name).detect { |c| c.name == column_name } || | |
raise(ActiveRecordError, "No such column: #{table_name}.#{column_name}") | |
end | |
def column_for_attribute(attribute) | |
table_name = attribute.relation.name | |
schema_cache.columns_hash(table_name)[attribute.name.to_s] | |
end | |
def collector | |
if prepared_statements | |
Arel::Collectors::Composite.new( | |
Arel::Collectors::SQLString.new, | |
Arel::Collectors::Bind.new, | |
) | |
else | |
Arel::Collectors::SubstituteBinds.new( | |
self, | |
Arel::Collectors::SQLString.new, | |
) | |
end | |
end | |
def arel_visitor | |
Arel::Visitors::ToSql.new(self) | |
end | |
def build_statement_pool | |
end | |
# Builds the result object. | |
# | |
# This is an internal hook to make possible connection adapters to build | |
# custom result objects with connection-specific data. | |
def build_result(columns:, rows:, column_types: {}) | |
ActiveRecord::Result.new(columns, rows, column_types) | |
end | |
# Perform any necessary initialization upon the newly-established | |
# @raw_connection -- this is the place to modify the adapter's | |
# connection settings, run queries to configure any application-global | |
# "session" variables, etc. | |
# | |
# Implementations may assume this method will only be called while | |
# holding @lock (or from #initialize). | |
def configure_connection | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_record/connection_adapters/abstract_adapter" | |
require "active_record/connection_adapters/statement_pool" | |
require "active_record/connection_adapters/mysql/column" | |
require "active_record/connection_adapters/mysql/explain_pretty_printer" | |
require "active_record/connection_adapters/mysql/quoting" | |
require "active_record/connection_adapters/mysql/schema_creation" | |
require "active_record/connection_adapters/mysql/schema_definitions" | |
require "active_record/connection_adapters/mysql/schema_dumper" | |
require "active_record/connection_adapters/mysql/schema_statements" | |
require "active_record/connection_adapters/mysql/type_metadata" | |
module ActiveRecord | |
module ConnectionAdapters | |
class AbstractMysqlAdapter < AbstractAdapter | |
include MySQL::Quoting | |
include MySQL::SchemaStatements | |
## | |
# :singleton-method: | |
# By default, the Mysql2Adapter will consider all columns of type <tt>tinyint(1)</tt> | |
# as boolean. If you wish to disable this emulation you can add the following line | |
# to your application.rb file: | |
# | |
# ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans = false | |
class_attribute :emulate_booleans, default: true | |
NATIVE_DATABASE_TYPES = { | |
primary_key: "bigint auto_increment PRIMARY KEY", | |
string: { name: "varchar", limit: 255 }, | |
text: { name: "text" }, | |
integer: { name: "int", limit: 4 }, | |
bigint: { name: "bigint" }, | |
float: { name: "float", limit: 24 }, | |
decimal: { name: "decimal" }, | |
datetime: { name: "datetime" }, | |
timestamp: { name: "timestamp" }, | |
time: { name: "time" }, | |
date: { name: "date" }, | |
binary: { name: "blob" }, | |
blob: { name: "blob" }, | |
boolean: { name: "tinyint", limit: 1 }, | |
json: { name: "json" }, | |
} | |
class StatementPool < ConnectionAdapters::StatementPool # :nodoc: | |
private | |
def dealloc(stmt) | |
stmt.close | |
end | |
end | |
def initialize(connection, logger, connection_options, config) | |
super(connection, logger, config) | |
end | |
def get_database_version # :nodoc: | |
full_version_string = get_full_version | |
version_string = version_string(full_version_string) | |
Version.new(version_string, full_version_string) | |
end | |
def mariadb? # :nodoc: | |
/mariadb/i.match?(full_version) | |
end | |
def supports_bulk_alter? | |
true | |
end | |
def supports_index_sort_order? | |
!mariadb? && database_version >= "8.0.1" | |
end | |
def supports_expression_index? | |
!mariadb? && database_version >= "8.0.13" | |
end | |
def supports_transaction_isolation? | |
true | |
end | |
def supports_restart_db_transaction? | |
true | |
end | |
def supports_explain? | |
true | |
end | |
def supports_indexes_in_create? | |
true | |
end | |
def supports_foreign_keys? | |
true | |
end | |
def supports_check_constraints? | |
if mariadb? | |
database_version >= "10.2.1" | |
else | |
database_version >= "8.0.16" | |
end | |
end | |
def supports_views? | |
true | |
end | |
def supports_datetime_with_precision? | |
mariadb? || database_version >= "5.6.4" | |
end | |
def supports_virtual_columns? | |
mariadb? || database_version >= "5.7.5" | |
end | |
# See https://dev.mysql.com/doc/refman/en/optimizer-hints.html for more details. | |
def supports_optimizer_hints? | |
!mariadb? && database_version >= "5.7.7" | |
end | |
def supports_common_table_expressions? | |
if mariadb? | |
database_version >= "10.2.1" | |
else | |
database_version >= "8.0.1" | |
end | |
end | |
def supports_advisory_locks? | |
true | |
end | |
def supports_insert_on_duplicate_skip? | |
true | |
end | |
def supports_insert_on_duplicate_update? | |
true | |
end | |
def field_ordered_value(column, values) # :nodoc: | |
field = Arel::Nodes::NamedFunction.new("FIELD", [column, values.reverse]) | |
Arel::Nodes::Descending.new(field) | |
end | |
def get_advisory_lock(lock_name, timeout = 0) # :nodoc: | |
query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1 | |
end | |
def release_advisory_lock(lock_name) # :nodoc: | |
query_value("SELECT RELEASE_LOCK(#{quote(lock_name.to_s)})") == 1 | |
end | |
def native_database_types | |
NATIVE_DATABASE_TYPES | |
end | |
def index_algorithms | |
{ | |
default: "ALGORITHM = DEFAULT", | |
copy: "ALGORITHM = COPY", | |
inplace: "ALGORITHM = INPLACE", | |
instant: "ALGORITHM = INSTANT", | |
} | |
end | |
# HELPER METHODS =========================================== | |
# The two drivers have slightly different ways of yielding hashes of results, so | |
# this method must be implemented to provide a uniform interface. | |
def each_hash(result) # :nodoc: | |
raise NotImplementedError | |
end | |
# Must return the MySQL error number from the exception, if the exception has an | |
# error number. | |
def error_number(exception) # :nodoc: | |
raise NotImplementedError | |
end | |
# REFERENTIAL INTEGRITY ==================================== | |
def disable_referential_integrity # :nodoc: | |
old = query_value("SELECT @@FOREIGN_KEY_CHECKS") | |
begin | |
update("SET FOREIGN_KEY_CHECKS = 0") | |
yield | |
ensure | |
update("SET FOREIGN_KEY_CHECKS = #{old}") | |
end | |
end | |
#-- | |
# DATABASE STATEMENTS ====================================== | |
#++ | |
# Executes the SQL statement in the context of this connection. | |
def execute(sql, name = nil, async: false) | |
raw_execute(sql, name, async: async) | |
end | |
# Mysql2Adapter doesn't have to free a result after using it, but we use this method | |
# to write stuff in an abstract way without concerning ourselves about whether it | |
# needs to be explicitly freed or not. | |
def execute_and_free(sql, name = nil, async: false) # :nodoc: | |
yield execute(sql, name, async: async) | |
end | |
def begin_db_transaction # :nodoc: | |
execute("BEGIN", "TRANSACTION") | |
end | |
def begin_isolated_db_transaction(isolation) # :nodoc: | |
execute "SET TRANSACTION ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}" | |
begin_db_transaction | |
end | |
def commit_db_transaction # :nodoc: | |
execute("COMMIT", "TRANSACTION") | |
end | |
def exec_rollback_db_transaction # :nodoc: | |
execute("ROLLBACK", "TRANSACTION") | |
end | |
def exec_restart_db_transaction # :nodoc: | |
execute("ROLLBACK AND CHAIN", "TRANSACTION") | |
end | |
def empty_insert_statement_value(primary_key = nil) # :nodoc: | |
"VALUES ()" | |
end | |
# SCHEMA STATEMENTS ======================================== | |
# Drops the database specified on the +name+ attribute | |
# and creates it again using the provided +options+. | |
def recreate_database(name, options = {}) | |
drop_database(name) | |
sql = create_database(name, options) | |
reconnect! | |
sql | |
end | |
# Create a new MySQL database with optional <tt>:charset</tt> and <tt>:collation</tt>. | |
# Charset defaults to utf8mb4. | |
# | |
# Example: | |
# create_database 'charset_test', charset: 'latin1', collation: 'latin1_bin' | |
# create_database 'matt_development' | |
# create_database 'matt_development', charset: :big5 | |
def create_database(name, options = {}) | |
if options[:collation] | |
execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT COLLATE #{quote_table_name(options[:collation])}" | |
elsif options[:charset] | |
execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset])}" | |
elsif row_format_dynamic_by_default? | |
execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET `utf8mb4`" | |
else | |
raise "Configure a supported :charset and ensure innodb_large_prefix is enabled to support indexes on varchar(255) string columns." | |
end | |
end | |
# Drops a MySQL database. | |
# | |
# Example: | |
# drop_database('sebastian_development') | |
def drop_database(name) # :nodoc: | |
execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}" | |
end | |
def current_database | |
query_value("SELECT database()", "SCHEMA") | |
end | |
# Returns the database character set. | |
def charset | |
show_variable "character_set_database" | |
end | |
# Returns the database collation strategy. | |
def collation | |
show_variable "collation_database" | |
end | |
def table_comment(table_name) # :nodoc: | |
scope = quoted_scope(table_name) | |
query_value(<<~SQL, "SCHEMA").presence | |
SELECT table_comment | |
FROM information_schema.tables | |
WHERE table_schema = #{scope[:schema]} | |
AND table_name = #{scope[:name]} | |
SQL | |
end | |
def change_table_comment(table_name, comment_or_changes) # :nodoc: | |
comment = extract_new_comment_value(comment_or_changes) | |
comment = "" if comment.nil? | |
execute("ALTER TABLE #{quote_table_name(table_name)} COMMENT #{quote(comment)}") | |
end | |
# Renames a table. | |
# | |
# Example: | |
# rename_table('octopuses', 'octopi') | |
def rename_table(table_name, new_name) | |
schema_cache.clear_data_source_cache!(table_name.to_s) | |
schema_cache.clear_data_source_cache!(new_name.to_s) | |
execute "RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}" | |
rename_table_indexes(table_name, new_name) | |
end | |
# Drops a table from the database. | |
# | |
# [<tt>:force</tt>] | |
# Set to +:cascade+ to drop dependent objects as well. | |
# Defaults to false. | |
# [<tt>:if_exists</tt>] | |
# Set to +true+ to only drop the table if it exists. | |
# Defaults to false. | |
# [<tt>:temporary</tt>] | |
# Set to +true+ to drop temporary table. | |
# Defaults to false. | |
# | |
# Although this command ignores most +options+ and the block if one is given, | |
# it can be helpful to provide these in a migration's +change+ method so it can be reverted. | |
# In that case, +options+ and the block will be used by create_table. | |
def drop_table(table_name, **options) | |
schema_cache.clear_data_source_cache!(table_name.to_s) | |
execute "DROP#{' TEMPORARY' if options[:temporary]} TABLE#{' IF EXISTS' if options[:if_exists]} #{quote_table_name(table_name)}#{' CASCADE' if options[:force] == :cascade}" | |
end | |
def rename_index(table_name, old_name, new_name) | |
if supports_rename_index? | |
validate_index_length!(table_name, new_name) | |
execute "ALTER TABLE #{quote_table_name(table_name)} RENAME INDEX #{quote_table_name(old_name)} TO #{quote_table_name(new_name)}" | |
else | |
super | |
end | |
end | |
def change_column_default(table_name, column_name, default_or_changes) # :nodoc: | |
default = extract_new_default_value(default_or_changes) | |
change_column table_name, column_name, nil, default: default | |
end | |
def change_column_null(table_name, column_name, null, default = nil) # :nodoc: | |
unless null || default.nil? | |
execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL") | |
end | |
change_column table_name, column_name, nil, null: null | |
end | |
def change_column_comment(table_name, column_name, comment_or_changes) # :nodoc: | |
comment = extract_new_comment_value(comment_or_changes) | |
change_column table_name, column_name, nil, comment: comment | |
end | |
def change_column(table_name, column_name, type, **options) # :nodoc: | |
execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_for_alter(table_name, column_name, type, **options)}") | |
end | |
def rename_column(table_name, column_name, new_column_name) # :nodoc: | |
execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_for_alter(table_name, column_name, new_column_name)}") | |
rename_column_indexes(table_name, column_name, new_column_name) | |
end | |
def add_index(table_name, column_name, **options) # :nodoc: | |
index, algorithm, if_not_exists = add_index_options(table_name, column_name, **options) | |
return if if_not_exists && index_exists?(table_name, column_name, name: index.name) | |
create_index = CreateIndexDefinition.new(index, algorithm) | |
execute schema_creation.accept(create_index) | |
end | |
def add_sql_comment!(sql, comment) # :nodoc: | |
sql << " COMMENT #{quote(comment)}" if comment.present? | |
sql | |
end | |
def foreign_keys(table_name) | |
raise ArgumentError unless table_name.present? | |
scope = quoted_scope(table_name) | |
fk_info = exec_query(<<~SQL, "SCHEMA") | |
SELECT fk.referenced_table_name AS 'to_table', | |
fk.referenced_column_name AS 'primary_key', | |
fk.column_name AS 'column', | |
fk.constraint_name AS 'name', | |
rc.update_rule AS 'on_update', | |
rc.delete_rule AS 'on_delete' | |
FROM information_schema.referential_constraints rc | |
JOIN information_schema.key_column_usage fk | |
USING (constraint_schema, constraint_name) | |
WHERE fk.referenced_column_name IS NOT NULL | |
AND fk.table_schema = #{scope[:schema]} | |
AND fk.table_name = #{scope[:name]} | |
AND rc.constraint_schema = #{scope[:schema]} | |
AND rc.table_name = #{scope[:name]} | |
SQL | |
fk_info.map do |row| | |
options = { | |
column: row["column"], | |
name: row["name"], | |
primary_key: row["primary_key"] | |
} | |
options[:on_update] = extract_foreign_key_action(row["on_update"]) | |
options[:on_delete] = extract_foreign_key_action(row["on_delete"]) | |
ForeignKeyDefinition.new(table_name, row["to_table"], options) | |
end | |
end | |
def check_constraints(table_name) | |
if supports_check_constraints? | |
scope = quoted_scope(table_name) | |
sql = <<~SQL | |
SELECT cc.constraint_name AS 'name', | |
cc.check_clause AS 'expression' | |
FROM information_schema.check_constraints cc | |
JOIN information_schema.table_constraints tc | |
USING (constraint_schema, constraint_name) | |
WHERE tc.table_schema = #{scope[:schema]} | |
AND tc.table_name = #{scope[:name]} | |
AND cc.constraint_schema = #{scope[:schema]} | |
SQL | |
sql += " AND cc.table_name = #{scope[:name]}" if mariadb? | |
chk_info = exec_query(sql, "SCHEMA") | |
chk_info.map do |row| | |
options = { | |
name: row["name"] | |
} | |
expression = row["expression"] | |
expression = expression[1..-2] unless mariadb? # remove parentheses added by mysql | |
CheckConstraintDefinition.new(table_name, expression, options) | |
end | |
else | |
raise NotImplementedError | |
end | |
end | |
def table_options(table_name) # :nodoc: | |
create_table_info = create_table_info(table_name) | |
# strip create_definitions and partition_options | |
# Be aware that `create_table_info` might not include any table options due to `NO_TABLE_OPTIONS` sql mode. | |
raw_table_options = create_table_info.sub(/\A.*\n\) ?/m, "").sub(/\n\/\*!.*\*\/\n\z/m, "").strip | |
return if raw_table_options.empty? | |
table_options = {} | |
if / DEFAULT CHARSET=(?<charset>\w+)(?: COLLATE=(?<collation>\w+))?/ =~ raw_table_options | |
raw_table_options = $` + $' # before part + after part | |
table_options[:charset] = charset | |
table_options[:collation] = collation if collation | |
end | |
# strip AUTO_INCREMENT | |
raw_table_options.sub!(/(ENGINE=\w+)(?: AUTO_INCREMENT=\d+)/, '\1') | |
# strip COMMENT | |
if raw_table_options.sub!(/ COMMENT='.+'/, "") | |
table_options[:comment] = table_comment(table_name) | |
end | |
table_options[:options] = raw_table_options unless raw_table_options == "ENGINE=InnoDB" | |
table_options | |
end | |
# SHOW VARIABLES LIKE 'name' | |
def show_variable(name) | |
query_value("SELECT @@#{name}", "SCHEMA") | |
rescue ActiveRecord::StatementInvalid | |
nil | |
end | |
def primary_keys(table_name) # :nodoc: | |
raise ArgumentError unless table_name.present? | |
scope = quoted_scope(table_name) | |
query_values(<<~SQL, "SCHEMA") | |
SELECT column_name | |
FROM information_schema.statistics | |
WHERE index_name = 'PRIMARY' | |
AND table_schema = #{scope[:schema]} | |
AND table_name = #{scope[:name]} | |
ORDER BY seq_in_index | |
SQL | |
end | |
def case_sensitive_comparison(attribute, value) # :nodoc: | |
column = column_for_attribute(attribute) | |
if column.collation && !column.case_sensitive? | |
attribute.eq(Arel::Nodes::Bin.new(value)) | |
else | |
super | |
end | |
end | |
def can_perform_case_insensitive_comparison_for?(column) | |
column.case_sensitive? | |
end | |
private :can_perform_case_insensitive_comparison_for? | |
# In MySQL 5.7.5 and up, ONLY_FULL_GROUP_BY affects handling of queries that use | |
# DISTINCT and ORDER BY. It requires the ORDER BY columns in the select list for | |
# distinct queries, and requires that the ORDER BY include the distinct column. | |
# See https://dev.mysql.com/doc/refman/en/group-by-handling.html | |
def columns_for_distinct(columns, orders) # :nodoc: | |
order_columns = orders.compact_blank.map { |s| | |
# Convert Arel node to string | |
s = visitor.compile(s) unless s.is_a?(String) | |
# Remove any ASC/DESC modifiers | |
s.gsub(/\s+(?:ASC|DESC)\b/i, "") | |
}.compact_blank.map.with_index { |column, i| "#{column} AS alias_#{i}" } | |
(order_columns << super).join(", ") | |
end | |
def strict_mode? | |
self.class.type_cast_config_to_boolean(@config.fetch(:strict, true)) | |
end | |
def default_index_type?(index) # :nodoc: | |
index.using == :btree || super | |
end | |
def build_insert_sql(insert) # :nodoc: | |
sql = +"INSERT #{insert.into} #{insert.values_list}" | |
if insert.skip_duplicates? | |
no_op_column = quote_column_name(insert.keys.first) | |
sql << " ON DUPLICATE KEY UPDATE #{no_op_column}=#{no_op_column}" | |
elsif insert.update_duplicates? | |
sql << " ON DUPLICATE KEY UPDATE " | |
if insert.raw_update_sql? | |
sql << insert.raw_update_sql | |
else | |
sql << insert.touch_model_timestamps_unless { |column| "#{column}<=>VALUES(#{column})" } | |
sql << insert.updatable_columns.map { |column| "#{column}=VALUES(#{column})" }.join(",") | |
end | |
end | |
sql | |
end | |
def check_version # :nodoc: | |
if database_version < "5.5.8" | |
raise "Your version of MySQL (#{database_version}) is too old. Active Record supports MySQL >= 5.5.8." | |
end | |
end | |
class << self | |
def extended_type_map(default_timezone: nil, emulate_booleans:) # :nodoc: | |
super(default_timezone: default_timezone).tap do |m| | |
if emulate_booleans | |
m.register_type %r(^tinyint\(1\))i, Type::Boolean.new | |
end | |
end | |
end | |
private | |
def initialize_type_map(m) | |
super | |
m.register_type(%r(char)i) do |sql_type| | |
limit = extract_limit(sql_type) | |
Type.lookup(:string, adapter: :mysql2, limit: limit) | |
end | |
m.register_type %r(tinytext)i, Type::Text.new(limit: 2**8 - 1) | |
m.register_type %r(tinyblob)i, Type::Binary.new(limit: 2**8 - 1) | |
m.register_type %r(text)i, Type::Text.new(limit: 2**16 - 1) | |
m.register_type %r(blob)i, Type::Binary.new(limit: 2**16 - 1) | |
m.register_type %r(mediumtext)i, Type::Text.new(limit: 2**24 - 1) | |
m.register_type %r(mediumblob)i, Type::Binary.new(limit: 2**24 - 1) | |
m.register_type %r(longtext)i, Type::Text.new(limit: 2**32 - 1) | |
m.register_type %r(longblob)i, Type::Binary.new(limit: 2**32 - 1) | |
m.register_type %r(^float)i, Type::Float.new(limit: 24) | |
m.register_type %r(^double)i, Type::Float.new(limit: 53) | |
register_integer_type m, %r(^bigint)i, limit: 8 | |
register_integer_type m, %r(^int)i, limit: 4 | |
register_integer_type m, %r(^mediumint)i, limit: 3 | |
register_integer_type m, %r(^smallint)i, limit: 2 | |
register_integer_type m, %r(^tinyint)i, limit: 1 | |
m.alias_type %r(year)i, "integer" | |
m.alias_type %r(bit)i, "binary" | |
m.register_type %r(^enum)i, Type.lookup(:string, adapter: :mysql2) | |
m.register_type %r(^set)i, Type.lookup(:string, adapter: :mysql2) | |
end | |
def register_integer_type(mapping, key, **options) | |
mapping.register_type(key) do |sql_type| | |
if /\bunsigned\b/.match?(sql_type) | |
Type::UnsignedInteger.new(**options) | |
else | |
Type::Integer.new(**options) | |
end | |
end | |
end | |
def extract_precision(sql_type) | |
if /\A(?:date)?time(?:stamp)?\b/.match?(sql_type) | |
super || 0 | |
else | |
super | |
end | |
end | |
end | |
TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) } | |
EXTENDED_TYPE_MAPS = Concurrent::Map.new | |
EMULATE_BOOLEANS_TRUE = { emulate_booleans: true }.freeze | |
private | |
def extended_type_map_key | |
if @default_timezone | |
{ default_timezone: @default_timezone, emulate_booleans: emulate_booleans } | |
elsif emulate_booleans | |
EMULATE_BOOLEANS_TRUE | |
end | |
end | |
def raw_execute(sql, name, async: false) | |
materialize_transactions | |
mark_transaction_written_if_write(sql) | |
log(sql, name, async: async) do | |
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do | |
@raw_connection.query(sql) | |
end | |
end | |
end | |
# See https://dev.mysql.com/doc/mysql-errors/en/server-error-reference.html | |
ER_DB_CREATE_EXISTS = 1007 | |
ER_FILSORT_ABORT = 1028 | |
ER_DUP_ENTRY = 1062 | |
ER_NOT_NULL_VIOLATION = 1048 | |
ER_NO_REFERENCED_ROW = 1216 | |
ER_ROW_IS_REFERENCED = 1217 | |
ER_DO_NOT_HAVE_DEFAULT = 1364 | |
ER_ROW_IS_REFERENCED_2 = 1451 | |
ER_NO_REFERENCED_ROW_2 = 1452 | |
ER_DATA_TOO_LONG = 1406 | |
ER_OUT_OF_RANGE = 1264 | |
ER_LOCK_DEADLOCK = 1213 | |
ER_CANNOT_ADD_FOREIGN = 1215 | |
ER_CANNOT_CREATE_TABLE = 1005 | |
ER_LOCK_WAIT_TIMEOUT = 1205 | |
ER_QUERY_INTERRUPTED = 1317 | |
ER_QUERY_TIMEOUT = 3024 | |
ER_FK_INCOMPATIBLE_COLUMNS = 3780 | |
def translate_exception(exception, message:, sql:, binds:) | |
case error_number(exception) | |
when nil | |
if exception.message.match?(/MySQL client is not connected/i) | |
ConnectionNotEstablished.new(exception) | |
else | |
super | |
end | |
when ER_DB_CREATE_EXISTS | |
DatabaseAlreadyExists.new(message, sql: sql, binds: binds) | |
when ER_DUP_ENTRY | |
RecordNotUnique.new(message, sql: sql, binds: binds) | |
when ER_NO_REFERENCED_ROW, ER_ROW_IS_REFERENCED, ER_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2 | |
InvalidForeignKey.new(message, sql: sql, binds: binds) | |
when ER_CANNOT_ADD_FOREIGN, ER_FK_INCOMPATIBLE_COLUMNS | |
mismatched_foreign_key(message, sql: sql, binds: binds) | |
when ER_CANNOT_CREATE_TABLE | |
if message.include?("errno: 150") | |
mismatched_foreign_key(message, sql: sql, binds: binds) | |
else | |
super | |
end | |
when ER_DATA_TOO_LONG | |
ValueTooLong.new(message, sql: sql, binds: binds) | |
when ER_OUT_OF_RANGE | |
RangeError.new(message, sql: sql, binds: binds) | |
when ER_NOT_NULL_VIOLATION, ER_DO_NOT_HAVE_DEFAULT | |
NotNullViolation.new(message, sql: sql, binds: binds) | |
when ER_LOCK_DEADLOCK | |
Deadlocked.new(message, sql: sql, binds: binds) | |
when ER_LOCK_WAIT_TIMEOUT | |
LockWaitTimeout.new(message, sql: sql, binds: binds) | |
when ER_QUERY_TIMEOUT, ER_FILSORT_ABORT | |
StatementTimeout.new(message, sql: sql, binds: binds) | |
when ER_QUERY_INTERRUPTED | |
QueryCanceled.new(message, sql: sql, binds: binds) | |
else | |
super | |
end | |
end | |
def change_column_for_alter(table_name, column_name, type, **options) | |
column = column_for(table_name, column_name) | |
type ||= column.sql_type | |
unless options.key?(:default) | |
options[:default] = column.default | |
end | |
unless options.key?(:null) | |
options[:null] = column.null | |
end | |
unless options.key?(:comment) | |
options[:comment] = column.comment | |
end | |
unless options.key?(:auto_increment) | |
options[:auto_increment] = column.auto_increment? | |
end | |
td = create_table_definition(table_name) | |
cd = td.new_column_definition(column.name, type, **options) | |
schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) | |
end | |
def rename_column_for_alter(table_name, column_name, new_column_name) | |
return rename_column_sql(table_name, column_name, new_column_name) if supports_rename_column? | |
column = column_for(table_name, column_name) | |
options = { | |
default: column.default, | |
null: column.null, | |
auto_increment: column.auto_increment?, | |
comment: column.comment | |
} | |
current_type = exec_query("SHOW COLUMNS FROM #{quote_table_name(table_name)} LIKE #{quote(column_name)}", "SCHEMA").first["Type"] | |
td = create_table_definition(table_name) | |
cd = td.new_column_definition(new_column_name, current_type, **options) | |
schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) | |
end | |
def add_index_for_alter(table_name, column_name, **options) | |
index, algorithm, _ = add_index_options(table_name, column_name, **options) | |
algorithm = ", #{algorithm}" if algorithm | |
"ADD #{schema_creation.accept(index)}#{algorithm}" | |
end | |
def remove_index_for_alter(table_name, column_name = nil, **options) | |
index_name = index_name_for_remove(table_name, column_name, options) | |
"DROP INDEX #{quote_column_name(index_name)}" | |
end | |
def supports_rename_index? | |
if mariadb? | |
database_version >= "10.5.2" | |
else | |
database_version >= "5.7.6" | |
end | |
end | |
def supports_rename_column? | |
if mariadb? | |
database_version >= "10.5.2" | |
else | |
database_version >= "8.0.3" | |
end | |
end | |
def configure_connection | |
variables = @config.fetch(:variables, {}).stringify_keys | |
# By default, MySQL 'where id is null' selects the last inserted id; Turn this off. | |
variables["sql_auto_is_null"] = 0 | |
# Increase timeout so the server doesn't disconnect us. | |
wait_timeout = self.class.type_cast_config_to_integer(@config[:wait_timeout]) | |
wait_timeout = 2147483 unless wait_timeout.is_a?(Integer) | |
variables["wait_timeout"] = wait_timeout | |
defaults = [":default", :default].to_set | |
# Make MySQL reject illegal values rather than truncating or blanking them, see | |
# https://dev.mysql.com/doc/refman/en/sql-mode.html#sqlmode_strict_all_tables | |
# If the user has provided another value for sql_mode, don't replace it. | |
if sql_mode = variables.delete("sql_mode") | |
sql_mode = quote(sql_mode) | |
elsif !defaults.include?(strict_mode?) | |
if strict_mode? | |
sql_mode = "CONCAT(@@sql_mode, ',STRICT_ALL_TABLES')" | |
else | |
sql_mode = "REPLACE(@@sql_mode, 'STRICT_TRANS_TABLES', '')" | |
sql_mode = "REPLACE(#{sql_mode}, 'STRICT_ALL_TABLES', '')" | |
sql_mode = "REPLACE(#{sql_mode}, 'TRADITIONAL', '')" | |
end | |
sql_mode = "CONCAT(#{sql_mode}, ',NO_AUTO_VALUE_ON_ZERO')" | |
end | |
sql_mode_assignment = "@@SESSION.sql_mode = #{sql_mode}, " if sql_mode | |
# NAMES does not have an equals sign, see | |
# https://dev.mysql.com/doc/refman/en/set-names.html | |
# (trailing comma because variable_assignments will always have content) | |
if @config[:encoding] | |
encoding = +"NAMES #{@config[:encoding]}" | |
encoding << " COLLATE #{@config[:collation]}" if @config[:collation] | |
encoding << ", " | |
end | |
# Gather up all of the SET variables... | |
variable_assignments = variables.filter_map do |k, v| | |
if defaults.include?(v) | |
"@@SESSION.#{k} = DEFAULT" # Sets the value to the global or compile default | |
elsif !v.nil? | |
"@@SESSION.#{k} = #{quote(v)}" | |
end | |
end.join(", ") | |
# ...and send them all in one query | |
execute("SET #{encoding} #{sql_mode_assignment} #{variable_assignments}", "SCHEMA") | |
end | |
def column_definitions(table_name) # :nodoc: | |
execute_and_free("SHOW FULL FIELDS FROM #{quote_table_name(table_name)}", "SCHEMA") do |result| | |
each_hash(result) | |
end | |
end | |
def create_table_info(table_name) # :nodoc: | |
exec_query("SHOW CREATE TABLE #{quote_table_name(table_name)}", "SCHEMA").first["Create Table"] | |
end | |
def arel_visitor | |
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+)`?.+? | |
FOREIGN\s+KEY\s*\(`?(?<foreign_key>\w+)`?\)\s* | |
REFERENCES\s*(`?(?<target_table>\w+)`?)\s*\(`?(?<primary_key>\w+)`?\) | |
/xmi.match(sql) | |
options = { | |
message: message, | |
sql: sql, | |
binds: binds, | |
} | |
if match | |
options[:table] = match[:table] | |
options[:foreign_key] = match[:foreign_key] | |
options[:target_table] = match[:target_table] | |
options[:primary_key] = match[:primary_key] | |
options[:primary_key_column] = column_for(match[:target_table], match[:primary_key]) | |
end | |
MismatchedForeignKey.new(**options) | |
end | |
def version_string(full_version_string) | |
full_version_string.match(/^(?:5\.5\.5-)?(\d+\.\d+\.\d+)/)[1] | |
end | |
ActiveRecord::Type.register(:immutable_string, adapter: :mysql2) do |_, **args| | |
Type::ImmutableString.new(true: "1", false: "0", **args) | |
end | |
ActiveRecord::Type.register(:string, adapter: :mysql2) do |_, **args| | |
Type::String.new(true: "1", false: "0", **args) | |
end | |
ActiveRecord::Type.register(:unsigned_integer, Type::UnsignedInteger, adapter: :mysql2) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
class AbstractMysqlAdapterTest < ActiveRecord::Mysql2TestCase | |
if current_adapter?(:Mysql2Adapter) | |
class ExampleMysqlAdapter < ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter; end | |
def setup | |
@conn = ExampleMysqlAdapter.new( | |
ActiveRecord::ConnectionAdapters::Mysql2Adapter.new_client({}), | |
ActiveRecord::Base.logger, | |
nil, | |
{ socket: File::NULL } | |
) | |
end | |
def test_execute_not_raising_error | |
assert_nothing_raised do | |
@conn.execute("SELECT 1") | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
ORIG_ARGV = ARGV.dup | |
require "bundler/setup" | |
require "active_support/core_ext/kernel/reporting" | |
silence_warnings do | |
Encoding.default_internal = Encoding::UTF_8 | |
Encoding.default_external = Encoding::UTF_8 | |
end | |
require "active_support/testing/autorun" | |
require "active_support/testing/method_call_assertions" | |
ENV["NO_RELOAD"] = "1" | |
require "active_support" | |
Thread.abort_on_exception = true | |
# Show backtraces for deprecated behavior for quicker cleanup. | |
ActiveSupport::Deprecation.debug = true | |
# Default to old to_time behavior but allow running tests with new behavior | |
ActiveSupport.to_time_preserves_timezone = ENV["PRESERVE_TIMEZONES"] == "1" | |
# Disable available locale checks to avoid warnings running the test suite. | |
I18n.enforce_available_locales = false | |
class ActiveSupport::TestCase | |
if Process.respond_to?(:fork) && !Gem.win_platform? | |
parallelize | |
else | |
parallelize(with: :threads) | |
end | |
include ActiveSupport::Testing::MethodCallAssertions | |
private | |
# Skips the current run on Rubinius using Minitest::Assertions#skip | |
def rubinius_skip(message = "") | |
skip message if RUBY_ENGINE == "rbx" | |
end | |
# Skips the current run on JRuby using Minitest::Assertions#skip | |
def jruby_skip(message = "") | |
skip message if defined?(JRUBY_VERSION) | |
end | |
end | |
require_relative "../../tools/test_common" |
# frozen_string_literal: true | |
module ActiveModel | |
module Validations | |
class AcceptanceValidator < EachValidator # :nodoc: | |
def initialize(options) | |
super({ allow_nil: true, accept: ["1", true] }.merge!(options)) | |
setup!(options[:class]) | |
end | |
def validate_each(record, attribute, value) | |
unless acceptable_option?(value) | |
record.errors.add(attribute, :accepted, **options.except(:accept, :allow_nil)) | |
end | |
end | |
private | |
def setup!(klass) | |
define_attributes = LazilyDefineAttributes.new(attributes) | |
klass.include(define_attributes) unless klass.included_modules.include?(define_attributes) | |
end | |
def acceptable_option?(value) | |
Array(options[:accept]).include?(value) | |
end | |
class LazilyDefineAttributes < Module | |
def initialize(attributes) | |
@attributes = attributes.map(&:to_s) | |
end | |
def included(klass) | |
@lock = Mutex.new | |
mod = self | |
define_method(:respond_to_missing?) do |method_name, include_private = false| | |
mod.define_on(klass) | |
super(method_name, include_private) || mod.matches?(method_name) | |
end | |
define_method(:method_missing) do |method_name, *args, &block| | |
mod.define_on(klass) | |
if mod.matches?(method_name) | |
send(method_name, *args, &block) | |
else | |
super(method_name, *args, &block) | |
end | |
end | |
end | |
def matches?(method_name) | |
attr_name = method_name.to_s.chomp("=") | |
attributes.any? { |name| name == attr_name } | |
end | |
def define_on(klass) | |
@lock&.synchronize do | |
return unless @lock | |
attr_readers = attributes.reject { |name| klass.attribute_method?(name) } | |
attr_writers = attributes.reject { |name| klass.attribute_method?("#{name}=") } | |
attr_reader(*attr_readers) | |
attr_writer(*attr_writers) | |
remove_method :respond_to_missing? | |
remove_method :method_missing | |
@lock = nil | |
end | |
end | |
def ==(other) | |
self.class == other.class && attributes == other.attributes | |
end | |
protected | |
attr_reader :attributes | |
end | |
end | |
module HelperMethods | |
# Encapsulates the pattern of wanting to validate the acceptance of a | |
# terms of service check box (or similar agreement). | |
# | |
# class Person < ActiveRecord::Base | |
# validates_acceptance_of :terms_of_service | |
# validates_acceptance_of :eula, message: 'must be abided' | |
# end | |
# | |
# If the database column does not exist, the +terms_of_service+ attribute | |
# is entirely virtual. This check is performed only if +terms_of_service+ | |
# is not +nil+ and by default on save. | |
# | |
# Configuration options: | |
# * <tt>:message</tt> - A custom error message (default is: "must be | |
# accepted"). | |
# * <tt>:accept</tt> - Specifies a value that is considered accepted. | |
# Also accepts an array of possible values. The default value is | |
# an array ["1", true], which makes it easy to relate to an HTML | |
# checkbox. This should be set to, or include, +true+ if you are validating | |
# a database column, since the attribute is typecast from "1" to +true+ | |
# before validation. | |
# | |
# There is also a list of default options supported by every validator: | |
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. | |
# See ActiveModel::Validations::ClassMethods#validates for more information. | |
def validates_acceptance_of(*attr_names) | |
validates_with AcceptanceValidator, _merge_attributes(attr_names) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/topic" | |
require "models/reply" | |
require "models/person" | |
class AcceptanceValidationTest < ActiveModel::TestCase | |
teardown do | |
self.class.send(:remove_const, :TestClass) | |
end | |
def test_terms_of_service_agreement_no_acceptance | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:terms_of_service) | |
t = klass.new("title" => "We should not be confirmed") | |
assert_predicate t, :valid? | |
end | |
def test_terms_of_service_agreement | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:terms_of_service) | |
t = klass.new("title" => "We should be confirmed", "terms_of_service" => "") | |
assert_predicate t, :invalid? | |
assert_equal ["must be accepted"], t.errors[:terms_of_service] | |
t.terms_of_service = "1" | |
assert_predicate t, :valid? | |
end | |
def test_eula | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:eula, message: "must be abided") | |
t = klass.new("title" => "We should be confirmed", "eula" => "") | |
assert_predicate t, :invalid? | |
assert_equal ["must be abided"], t.errors[:eula] | |
t.eula = "1" | |
assert_predicate t, :valid? | |
end | |
def test_terms_of_service_agreement_with_accept_value | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:terms_of_service, accept: "I agree.") | |
t = klass.new("title" => "We should be confirmed", "terms_of_service" => "") | |
assert_predicate t, :invalid? | |
assert_equal ["must be accepted"], t.errors[:terms_of_service] | |
t.terms_of_service = "I agree." | |
assert_predicate t, :valid? | |
end | |
def test_terms_of_service_agreement_with_multiple_accept_values | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:terms_of_service, accept: [1, "I concur."]) | |
t = klass.new("title" => "We should be confirmed", "terms_of_service" => "") | |
assert_predicate t, :invalid? | |
assert_equal ["must be accepted"], t.errors[:terms_of_service] | |
t.terms_of_service = 1 | |
assert_predicate t, :valid? | |
t.terms_of_service = "I concur." | |
assert_predicate t, :valid? | |
end | |
def test_validates_acceptance_of_for_ruby_class | |
klass = define_test_class(Person) | |
klass.validates_acceptance_of :karma | |
p = klass.new | |
p.karma = "" | |
assert_predicate p, :invalid? | |
assert_equal ["must be accepted"], p.errors[:karma] | |
p.karma = "1" | |
assert_predicate p, :valid? | |
end | |
def test_validates_acceptance_of_true | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:terms_of_service) | |
assert_predicate klass.new(terms_of_service: true), :valid? | |
end | |
def test_lazy_attribute_module_included_only_once | |
klass = define_test_class(Topic) | |
assert_difference -> { klass.ancestors.count }, 2 do | |
2.times do | |
klass.validates_acceptance_of(:something_to_accept) | |
assert klass.new.respond_to?(:something_to_accept) | |
end | |
2.times do | |
klass.validates_acceptance_of(:something_else_to_accept) | |
assert klass.new.respond_to?(:something_else_to_accept) | |
end | |
end | |
end | |
def test_lazy_attributes_module_included_again_if_needed | |
klass = define_test_class(Topic) | |
assert_difference -> { klass.ancestors.count }, 1 do | |
klass.validates_acceptance_of(:something_to_accept) | |
end | |
topic = klass.new | |
topic.something_to_accept | |
assert_difference -> { klass.ancestors.count }, 1 do | |
klass.validates_acceptance_of(:something_else_to_accept) | |
end | |
assert topic.respond_to?(:something_else_to_accept) | |
end | |
def test_lazy_attributes_respond_to? | |
klass = define_test_class(Topic) | |
klass.validates_acceptance_of(:terms_of_service) | |
topic = klass.new | |
threads = [] | |
2.times do | |
threads << Thread.new do | |
assert topic.respond_to?(:terms_of_service) | |
end | |
end | |
threads.each(&:join) | |
end | |
private | |
def define_test_class(parent) | |
self.class.const_set(:TestClass, Class.new(parent)) | |
end | |
end |
# frozen_string_literal: true | |
module ActiveModel | |
module Type | |
module Helpers # :nodoc: all | |
class AcceptsMultiparameterTime < Module | |
module InstanceMethods | |
def serialize(value) | |
super(cast(value)) | |
end | |
def cast(value) | |
if value.is_a?(Hash) | |
value_from_multiparameter_assignment(value) | |
else | |
super(value) | |
end | |
end | |
def assert_valid_value(value) | |
if value.is_a?(Hash) | |
value_from_multiparameter_assignment(value) | |
else | |
super(value) | |
end | |
end | |
def value_constructed_by_mass_assignment?(value) | |
value.is_a?(Hash) | |
end | |
end | |
def initialize(defaults: {}) | |
include InstanceMethods | |
define_method(:value_from_multiparameter_assignment) do |values_hash| | |
defaults.each do |k, v| | |
values_hash[k] ||= v | |
end | |
return unless values_hash[1] && values_hash[2] && values_hash[3] | |
values = values_hash.sort.map!(&:last) | |
::Time.public_send(default_timezone, *values) | |
end | |
private :value_from_multiparameter_assignment | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
class String | |
# If you pass a single integer, returns a substring of one character at that | |
# position. The first character of the string is at position 0, the next at | |
# position 1, and so on. If a range is supplied, a substring containing | |
# characters at offsets given by the range is returned. In both cases, if an | |
# offset is negative, it is counted from the end of the string. Returns +nil+ | |
# if the initial offset falls outside the string. Returns an empty string if | |
# the beginning of the range is greater than the end of the string. | |
# | |
# str = "hello" | |
# str.at(0) # => "h" | |
# str.at(1..3) # => "ell" | |
# str.at(-2) # => "l" | |
# str.at(-2..-1) # => "lo" | |
# str.at(5) # => nil | |
# str.at(5..-1) # => "" | |
# | |
# If a Regexp is given, the matching portion of the string is returned. | |
# If a String is given, that given string is returned if it occurs in | |
# the string. In both cases, +nil+ is returned if there is no match. | |
# | |
# str = "hello" | |
# str.at(/lo/) # => "lo" | |
# str.at(/ol/) # => nil | |
# str.at("lo") # => "lo" | |
# str.at("ol") # => nil | |
def at(position) | |
self[position] | |
end | |
# Returns a substring from the given position to the end of the string. | |
# If the position is negative, it is counted from the end of the string. | |
# | |
# str = "hello" | |
# str.from(0) # => "hello" | |
# str.from(3) # => "lo" | |
# str.from(-2) # => "lo" | |
# | |
# You can mix it with +to+ method and do fun things like: | |
# | |
# str = "hello" | |
# str.from(0).to(-1) # => "hello" | |
# str.from(1).to(-2) # => "ell" | |
def from(position) | |
self[position, length] | |
end | |
# Returns a substring from the beginning of the string to the given position. | |
# If the position is negative, it is counted from the end of the string. | |
# | |
# str = "hello" | |
# str.to(0) # => "h" | |
# str.to(3) # => "hell" | |
# str.to(-2) # => "hell" | |
# | |
# You can mix it with +from+ method and do fun things like: | |
# | |
# str = "hello" | |
# str.from(0).to(-1) # => "hello" | |
# str.from(1).to(-2) # => "ell" | |
def to(position) | |
position += size if position < 0 | |
self[0, position + 1] || +"" | |
end | |
# Returns the first character. If a limit is supplied, returns a substring | |
# from the beginning of the string until it reaches the limit value. If the | |
# given limit is greater than or equal to the string length, returns a copy of self. | |
# | |
# str = "hello" | |
# str.first # => "h" | |
# str.first(1) # => "h" | |
# str.first(2) # => "he" | |
# str.first(0) # => "" | |
# str.first(6) # => "hello" | |
def first(limit = 1) | |
self[0, limit] || raise(ArgumentError, "negative limit") | |
end | |
# Returns the last character of the string. If a limit is supplied, returns a substring | |
# from the end of the string until it reaches the limit value (counting backwards). If | |
# the given limit is greater than or equal to the string length, returns a copy of self. | |
# | |
# str = "hello" | |
# str.last # => "o" | |
# str.last(1) # => "o" | |
# str.last(2) # => "lo" | |
# str.last(0) # => "" | |
# str.last(6) # => "hello" | |
def last(limit = 1) | |
self[[length - limit, 0].max, limit] || raise(ArgumentError, "negative limit") | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/array" | |
class AccessTest < ActiveSupport::TestCase | |
def test_from | |
assert_equal %w( a b c d ), %w( a b c d ).from(0) | |
assert_equal %w( c d ), %w( a b c d ).from(2) | |
assert_equal %w(), %w( a b c d ).from(10) | |
assert_equal %w( d e ), %w( a b c d e ).from(-2) | |
assert_equal %w(), %w( a b c d e ).from(-10) | |
end | |
def test_to | |
assert_equal %w( a ), %w( a b c d ).to(0) | |
assert_equal %w( a b c ), %w( a b c d ).to(2) | |
assert_equal %w( a b c d ), %w( a b c d ).to(10) | |
assert_equal %w( a b c ), %w( a b c d ).to(-2) | |
assert_equal %w(), %w( a b c ).to(-10) | |
end | |
def test_specific_accessor | |
array = (1..42).to_a | |
assert_equal array[1], array.second | |
assert_equal array[2], array.third | |
assert_equal array[3], array.fourth | |
assert_equal array[4], array.fifth | |
assert_equal array[41], array.forty_two | |
assert_equal array[-3], array.third_to_last | |
assert_equal array[-2], array.second_to_last | |
end | |
def test_including | |
assert_equal [1, 2, 3, 4, 5], [1, 2, 4].including(3, 5).sort | |
assert_equal [1, 2, 3, 4, 5], [1, 2, 4].including([3, 5]).sort | |
assert_equal [[0, 1], [1, 0]], [[0, 1]].including([[1, 0]]) | |
end | |
def test_excluding | |
assert_equal [1, 2, 4], [1, 2, 3, 4, 5].excluding(3, 5) | |
assert_equal [1, 2, 4], [1, 2, 3, 4, 5].excluding([3, 5]) | |
assert_equal [[0, 1]], [[0, 1], [1, 0]].excluding([[1, 0]]) | |
end | |
def test_without | |
assert_equal [1, 2, 4], [1, 2, 3, 4, 5].without(3, 5) | |
end | |
end |
# frozen_string_literal: true | |
class Admin::Account < ActiveRecord::Base | |
has_many :users | |
end |
# frozen_string_literal: true | |
module ActiveSupport | |
# Actionable errors lets you define actions to resolve an error. | |
# | |
# To make an error actionable, include the <tt>ActiveSupport::ActionableError</tt> | |
# module and invoke the +action+ class macro to define the action. An action | |
# needs a name and a block to execute. | |
module ActionableError | |
extend Concern | |
class NonActionable < StandardError; end | |
included do | |
class_attribute :_actions, default: {} | |
end | |
def self.actions(error) # :nodoc: | |
case error | |
when ActionableError, -> it { Class === it && it < ActionableError } | |
error._actions | |
else | |
{} | |
end | |
end | |
def self.dispatch(error, name) # :nodoc: | |
actions(error).fetch(name).call | |
rescue KeyError | |
raise NonActionable, "Cannot find action \"#{name}\"" | |
end | |
module ClassMethods | |
# Defines an action that can resolve the error. | |
# | |
# class PendingMigrationError < MigrationError | |
# include ActiveSupport::ActionableError | |
# | |
# action "Run pending migrations" do | |
# ActiveRecord::Tasks::DatabaseTasks.migrate | |
# end | |
# end | |
def action(name, &block) | |
_actions[name] = block | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "abstract_unit" | |
require "active_support/actionable_error" | |
class ActionableErrorTest < ActiveSupport::TestCase | |
NonActionableError = Class.new(StandardError) | |
class DispatchableError < StandardError | |
include ActiveSupport::ActionableError | |
class_attribute :flip1, default: false | |
class_attribute :flip2, default: false | |
action "Flip 1" do | |
self.flip1 = true | |
end | |
action "Flip 2" do | |
self.flip2 = true | |
end | |
end | |
test "returns all action of an actionable error" do | |
assert_equal ["Flip 1", "Flip 2"], ActiveSupport::ActionableError.actions(DispatchableError).keys | |
assert_equal ["Flip 1", "Flip 2"], ActiveSupport::ActionableError.actions(DispatchableError.new).keys | |
end | |
test "returns no actions for non-actionable errors" do | |
assert ActiveSupport::ActionableError.actions(Exception).empty? | |
assert ActiveSupport::ActionableError.actions(Exception.new).empty? | |
end | |
test "dispatches actions from error and name" do | |
assert_changes "DispatchableError.flip1", from: false, to: true do | |
ActiveSupport::ActionableError.dispatch DispatchableError, "Flip 1" | |
end | |
end | |
test "cannot dispatch missing actions" do | |
err = assert_raises ActiveSupport::ActionableError::NonActionable do | |
ActiveSupport::ActionableError.dispatch NonActionableError, "action" | |
end | |
assert_equal 'Cannot find action "action"', err.to_s | |
end | |
end |
# frozen_string_literal: true | |
#-- | |
# Copyright (c) 2014-2022 David Heinemeier Hansson | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining | |
# a copy of this software and associated documentation files (the | |
# "Software"), to deal in the Software without restriction, including | |
# without limitation the rights to use, copy, modify, merge, publish, | |
# distribute, sublicense, and/or sell copies of the Software, and to | |
# permit persons to whom the Software is furnished to do so, subject to | |
# the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be | |
# included in all copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
#++ | |
require "active_support" | |
require "active_support/rails" | |
require "active_job/version" | |
require "global_id" | |
module ActiveJob | |
extend ActiveSupport::Autoload | |
autoload :Base | |
autoload :QueueAdapters | |
eager_autoload do | |
autoload :Serializers | |
autoload :ConfiguredJob | |
end | |
autoload :TestCase | |
autoload :TestHelper | |
autoload :QueryTags | |
end |
# frozen_string_literal: true | |
#-- | |
# Copyright (c) 2004-2022 David Heinemeier Hansson | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining | |
# a copy of this software and associated documentation files (the | |
# "Software"), to deal in the Software without restriction, including | |
# without limitation the rights to use, copy, modify, merge, publish, | |
# distribute, sublicense, and/or sell copies of the Software, and to | |
# permit persons to whom the Software is furnished to do so, subject to | |
# the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be | |
# included in all copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
#++ | |
require "active_support" | |
require "active_support/rails" | |
require "active_model/version" | |
module ActiveModel | |
extend ActiveSupport::Autoload | |
autoload :Access | |
autoload :API | |
autoload :Attribute | |
autoload :Attributes | |
autoload :AttributeAssignment | |
autoload :AttributeMethods | |
autoload :BlockValidator, "active_model/validator" | |
autoload :Callbacks | |
autoload :Conversion | |
autoload :Dirty | |
autoload :EachValidator, "active_model/validator" | |
autoload :ForbiddenAttributesProtection | |
autoload :Lint | |
autoload :Model | |
autoload :Name, "active_model/naming" | |
autoload :Naming | |
autoload :SecurePassword | |
autoload :Serialization | |
autoload :Translation | |
autoload :Type | |
autoload :Validations | |
autoload :Validator | |
eager_autoload do | |
autoload :Errors | |
autoload :Error | |
autoload :RangeError, "active_model/errors" | |
autoload :StrictValidationFailed, "active_model/errors" | |
autoload :UnknownAttributeError, "active_model/errors" | |
end | |
module Serializers | |
extend ActiveSupport::Autoload | |
eager_autoload do | |
autoload :JSON | |
end | |
end | |
def self.eager_load! | |
super | |
ActiveModel::Serializers.eager_load! | |
end | |
end | |
ActiveSupport.on_load(:i18n) do | |
I18n.load_path << File.expand_path("active_model/locale/en.yml", __dir__) | |
end |
# frozen_string_literal: true | |
require "rails/generators/named_base" | |
require "rails/generators/active_model" | |
require "rails/generators/active_record/migration" | |
require "active_record" | |
module ActiveRecord | |
module Generators # :nodoc: | |
class Base < Rails::Generators::NamedBase # :nodoc: | |
include ActiveRecord::Generators::Migration | |
# Set the current directory as base for the inherited generators. | |
def self.base_root | |
__dir__ | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
class ActiveRecordSchemaTest < ActiveRecord::TestCase | |
self.use_transactional_tests = false | |
setup do | |
@original_verbose = ActiveRecord::Migration.verbose | |
ActiveRecord::Migration.verbose = false | |
@connection = ActiveRecord::Base.connection | |
@schema_migration = @connection.schema_migration | |
@schema_migration.drop_table | |
end | |
teardown do | |
@connection.drop_table :fruits rescue nil | |
@connection.drop_table :nep_fruits rescue nil | |
@connection.drop_table :nep_schema_migrations rescue nil | |
@connection.drop_table :has_timestamps rescue nil | |
@connection.drop_table :multiple_indexes rescue nil | |
@schema_migration.delete_all rescue nil | |
ActiveRecord::Migration.verbose = @original_verbose | |
end | |
def test_has_primary_key | |
old_primary_key_prefix_type = ActiveRecord::Base.primary_key_prefix_type | |
ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore | |
assert_equal "version", @schema_migration.primary_key | |
@schema_migration.create_table | |
assert_difference "@schema_migration.count", 1 do | |
@schema_migration.create version: 12 | |
end | |
ensure | |
@schema_migration.drop_table | |
ActiveRecord::Base.primary_key_prefix_type = old_primary_key_prefix_type | |
end | |
def test_schema_without_version_is_the_current_version_schema | |
schema_class = ActiveRecord::Schema | |
assert schema_class < ActiveRecord::Migration[ActiveRecord::Migration.current_version] | |
assert_not schema_class < ActiveRecord::Migration[7.0] | |
assert schema_class < ActiveRecord::Schema::Definition | |
end | |
def test_schema_version_accessor | |
schema_class = ActiveRecord::Schema[6.1] | |
assert schema_class < ActiveRecord::Migration[6.1] | |
assert schema_class < ActiveRecord::Schema::Definition | |
end | |
def test_schema_define | |
ActiveRecord::Schema.define(version: 7) do | |
create_table :fruits do |t| | |
t.column :color, :string | |
t.column :fruit_size, :string # NOTE: "size" is reserved in Oracle | |
t.column :texture, :string | |
t.column :flavor, :string | |
end | |
end | |
assert_nothing_raised { @connection.select_all "SELECT * FROM fruits" } | |
assert_nothing_raised { @connection.select_all "SELECT * FROM schema_migrations" } | |
assert_equal 7, @connection.schema_version | |
end | |
def test_schema_define_with_table_name_prefix | |
old_table_name_prefix = ActiveRecord::Base.table_name_prefix | |
ActiveRecord::Base.table_name_prefix = "nep_" | |
@schema_migration.reset_table_name | |
ActiveRecord::InternalMetadata.reset_table_name | |
ActiveRecord::Schema.define(version: 7) do | |
create_table :fruits do |t| | |
t.column :color, :string | |
t.column :fruit_size, :string # NOTE: "size" is reserved in Oracle | |
t.column :texture, :string | |
t.column :flavor, :string | |
end | |
end | |
assert_equal 7, @connection.migration_context.current_version | |
ensure | |
ActiveRecord::Base.table_name_prefix = old_table_name_prefix | |
@schema_migration.reset_table_name | |
ActiveRecord::InternalMetadata.reset_table_name | |
end | |
def test_schema_raises_an_error_for_invalid_column_type | |
assert_raise NoMethodError do | |
ActiveRecord::Schema.define(version: 8) do | |
create_table :vegetables do |t| | |
t.unknown :color | |
end | |
end | |
end | |
end | |
def test_schema_subclass | |
Class.new(ActiveRecord::Schema).define(version: 9) do | |
create_table :fruits | |
end | |
assert_nothing_raised { @connection.select_all "SELECT * FROM fruits" } | |
end | |
def test_normalize_version | |
assert_equal "118", @schema_migration.normalize_migration_number("0000118") | |
assert_equal "002", @schema_migration.normalize_migration_number("2") | |
assert_equal "017", @schema_migration.normalize_migration_number("0017") | |
assert_equal "20131219224947", @schema_migration.normalize_migration_number("20131219224947") | |
end | |
def test_schema_load_with_multiple_indexes_for_column_of_different_names | |
ActiveRecord::Schema.define do | |
create_table :multiple_indexes do |t| | |
t.string "foo" | |
t.index ["foo"], name: "multiple_indexes_foo_1" | |
t.index ["foo"], name: "multiple_indexes_foo_2" | |
end | |
end | |
indexes = @connection.indexes("multiple_indexes") | |
assert_equal 2, indexes.length | |
assert_equal ["multiple_indexes_foo_1", "multiple_indexes_foo_2"], indexes.collect(&:name).sort | |
end | |
if current_adapter?(:PostgreSQLAdapter) | |
def test_timestamps_with_and_without_zones | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps do |t| | |
t.datetime "default_format" | |
t.datetime "without_time_zone" | |
t.timestamp "also_without_time_zone" | |
t.timestamptz "with_time_zone" | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :default_format, :datetime) | |
assert @connection.column_exists?(:has_timestamps, :without_time_zone, :datetime) | |
assert @connection.column_exists?(:has_timestamps, :also_without_time_zone, :datetime) | |
assert @connection.column_exists?(:has_timestamps, :with_time_zone, :timestamptz) | |
end | |
end | |
def test_timestamps_without_null_set_null_to_false_on_create_table | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps do |t| | |
t.timestamps | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) | |
end | |
def test_timestamps_without_null_set_null_to_false_on_change_table | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps | |
change_table :has_timestamps do |t| | |
t.timestamps default: Time.now | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) | |
end | |
if ActiveRecord::Base.connection.supports_bulk_alter? | |
def test_timestamps_without_null_set_null_to_false_on_change_table_with_bulk | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps | |
change_table :has_timestamps, bulk: true do |t| | |
t.timestamps default: Time.now | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) | |
end | |
end | |
def test_timestamps_without_null_set_null_to_false_on_add_timestamps | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps | |
add_timestamps :has_timestamps, default: Time.now | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, null: false) | |
end | |
if supports_datetime_with_precision? | |
def test_timestamps_sets_precision_on_create_table | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps do |t| | |
t.timestamps | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) | |
end | |
def test_timestamps_sets_precision_on_change_table | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps | |
change_table :has_timestamps do |t| | |
t.timestamps default: Time.now | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) | |
end | |
if ActiveRecord::Base.connection.supports_bulk_alter? | |
def test_timestamps_sets_precision_on_change_table_with_bulk | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps | |
change_table :has_timestamps, bulk: true do |t| | |
t.timestamps default: Time.now | |
end | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) | |
end | |
end | |
def test_timestamps_sets_precision_on_add_timestamps | |
ActiveRecord::Schema.define do | |
create_table :has_timestamps | |
add_timestamps :has_timestamps, default: Time.now | |
end | |
assert @connection.column_exists?(:has_timestamps, :created_at, precision: 6, null: false) | |
assert @connection.column_exists?(:has_timestamps, :updated_at, precision: 6, null: false) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase | |
def setup | |
ActiveRecord::Base.connection.materialize_transactions | |
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.class_eval do | |
def execute(sql, name = nil) sql end | |
end | |
end | |
teardown do | |
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.class_eval do | |
remove_method :execute | |
end | |
end | |
def test_create_database_with_encoding | |
assert_equal %(CREATE DATABASE "matt" ENCODING = 'utf8'), create_database(:matt) | |
assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'latin1'), create_database(:aimonetti, encoding: :latin1) | |
assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'latin1'), create_database(:aimonetti, "encoding" => :latin1) | |
end | |
def test_create_database_with_collation_and_ctype | |
assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'UTF8' LC_COLLATE = 'ja_JP.UTF8' LC_CTYPE = 'ja_JP.UTF8'), create_database(:aimonetti, encoding: :"UTF8", collation: :"ja_JP.UTF8", ctype: :"ja_JP.UTF8") | |
end | |
def test_add_index | |
expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" ("last_name") WHERE state = 'active') | |
assert_equal expected, add_index(:people, :last_name, unique: true, where: "state = 'active'") | |
expected = %(CREATE UNIQUE INDEX "index_people_on_lower_last_name" ON "people" (lower(last_name))) | |
assert_equal expected, add_index(:people, "lower(last_name)", unique: true) | |
expected = %(CREATE UNIQUE INDEX "index_people_on_last_name_varchar_pattern_ops" ON "people" (last_name varchar_pattern_ops)) | |
assert_equal expected, add_index(:people, "last_name varchar_pattern_ops", unique: true) | |
expected = %(CREATE INDEX CONCURRENTLY "index_people_on_last_name" ON "people" ("last_name")) | |
assert_equal expected, add_index(:people, :last_name, algorithm: :concurrently) | |
expected = %(CREATE INDEX CONCURRENTLY IF NOT EXISTS "index_people_on_last_name" ON "people" ("last_name")) | |
assert_equal expected, add_index(:people, :last_name, if_not_exists: true, algorithm: :concurrently) | |
expected = %(CREATE INDEX "index_people_on_last_name_and_first_name" ON "people" ("last_name" DESC, "first_name" ASC)) | |
assert_equal expected, add_index(:people, [:last_name, :first_name], order: { last_name: :desc, first_name: :asc }) | |
assert_equal expected, add_index(:people, ["last_name", :first_name], order: { last_name: :desc, "first_name" => :asc }) | |
%w(gin gist hash btree).each do |type| | |
expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING #{type} ("last_name")) | |
assert_equal expected, add_index(:people, :last_name, using: type) | |
expected = %(CREATE INDEX CONCURRENTLY "index_people_on_last_name" ON "people" USING #{type} ("last_name")) | |
assert_equal expected, add_index(:people, :last_name, using: type, algorithm: :concurrently) | |
expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" USING #{type} ("last_name") WHERE state = 'active') | |
assert_equal expected, add_index(:people, :last_name, using: type, unique: true, where: "state = 'active'") | |
expected = %(CREATE UNIQUE INDEX "index_people_on_lower_last_name" ON "people" USING #{type} (lower(last_name))) | |
assert_equal expected, add_index(:people, "lower(last_name)", using: type, unique: true) | |
end | |
expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING gist ("last_name" bpchar_pattern_ops)) | |
assert_equal expected, add_index(:people, :last_name, using: :gist, opclass: { last_name: :bpchar_pattern_ops }) | |
expected = %(CREATE INDEX "index_people_on_last_name_and_first_name" ON "people" ("last_name" DESC NULLS LAST, "first_name" ASC)) | |
assert_equal expected, add_index(:people, [:last_name, :first_name], order: { last_name: "DESC NULLS LAST", first_name: :asc }) | |
expected = %(CREATE INDEX "index_people_on_last_name" ON "people" ("last_name" NULLS FIRST)) | |
assert_equal expected, add_index(:people, :last_name, order: "NULLS FIRST") | |
expected = %(CREATE INDEX IF NOT EXISTS "index_people_on_last_name" ON "people" ("last_name")) | |
assert_equal expected, add_index(:people, :last_name, if_not_exists: true) | |
assert_raise ArgumentError do | |
add_index(:people, :last_name, algorithm: :copy) | |
end | |
end | |
def test_remove_index | |
# remove_index calls index_name_for_remove which can't work since execute is stubbed | |
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.define_method(:index_name_for_remove) do |*| | |
"index_people_on_last_name" | |
end | |
expected = %(DROP INDEX CONCURRENTLY "index_people_on_last_name") | |
assert_equal expected, remove_index(:people, name: "index_people_on_last_name", algorithm: :concurrently) | |
assert_raise ArgumentError do | |
add_index(:people, :last_name, algorithm: :copy) | |
end | |
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.remove_method :index_name_for_remove | |
end | |
def test_remove_index_when_name_is_specified | |
expected = %(DROP INDEX CONCURRENTLY "index_people_on_last_name") | |
assert_equal expected, remove_index(:people, name: "index_people_on_last_name", algorithm: :concurrently) | |
end | |
def test_remove_index_with_wrong_option | |
assert_raises ArgumentError do | |
remove_index(:people, coulmn: :last_name) | |
end | |
end | |
private | |
def method_missing(...) | |
ActiveRecord::Base.connection.public_send(...) | |
end | |
end |
# frozen_string_literal: true | |
#-- | |
# Copyright (c) 2017-2022 David Heinemeier Hansson, Basecamp | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining | |
# a copy of this software and associated documentation files (the | |
# "Software"), to deal in the Software without restriction, including | |
# without limitation the rights to use, copy, modify, merge, publish, | |
# distribute, sublicense, and/or sell copies of the Software, and to | |
# permit persons to whom the Software is furnished to do so, subject to | |
# the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be | |
# included in all copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
#++ | |
require "active_record" | |
require "active_support" | |
require "active_support/rails" | |
require "active_support/core_ext/numeric/time" | |
require "active_storage/version" | |
require "active_storage/errors" | |
require "marcel" | |
module ActiveStorage | |
extend ActiveSupport::Autoload | |
autoload :Attached | |
autoload :FixtureSet | |
autoload :Service | |
autoload :Previewer | |
autoload :Analyzer | |
mattr_accessor :logger | |
mattr_accessor :verifier | |
mattr_accessor :variant_processor, default: :mini_magick | |
mattr_accessor :queues, default: {} | |
mattr_accessor :previewers, default: [] | |
mattr_accessor :analyzers, default: [] | |
mattr_accessor :paths, default: {} | |
mattr_accessor :variable_content_types, default: [] | |
mattr_accessor :web_image_content_types, default: [] | |
mattr_accessor :binary_content_type, default: "application/octet-stream" | |
mattr_accessor :content_types_to_serve_as_binary, default: [] | |
mattr_accessor :content_types_allowed_inline, default: [] | |
mattr_accessor :service_urls_expire_in, default: 5.minutes | |
mattr_accessor :urls_expire_in | |
mattr_accessor :routes_prefix, default: "/rails/active_storage" | |
mattr_accessor :draw_routes, default: true | |
mattr_accessor :resolve_model_to_route, default: :rails_storage_redirect | |
mattr_accessor :replace_on_assign_to_many, default: false | |
mattr_accessor :track_variants, default: false | |
mattr_accessor :video_preview_arguments, default: "-y -vframes 1 -f image2" | |
mattr_accessor :silence_invalid_content_types_warning, default: false | |
module Transformers | |
extend ActiveSupport::Autoload | |
autoload :Transformer | |
autoload :ImageProcessingTransformer | |
end | |
end |
# frozen_string_literal: true | |
#-- | |
# Copyright (c) 2005-2022 David Heinemeier Hansson | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining | |
# a copy of this software and associated documentation files (the | |
# "Software"), to deal in the Software without restriction, including | |
# without limitation the rights to use, copy, modify, merge, publish, | |
# distribute, sublicense, and/or sell copies of the Software, and to | |
# permit persons to whom the Software is furnished to do so, subject to | |
# the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be | |
# included in all copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
#++ | |
require "securerandom" | |
require "active_support/dependencies/autoload" | |
require "active_support/version" | |
require "active_support/logger" | |
require "active_support/lazy_load_hooks" | |
require "active_support/core_ext/date_and_time/compatibility" | |
module ActiveSupport | |
extend ActiveSupport::Autoload | |
autoload :Concern | |
autoload :CodeGenerator | |
autoload :ActionableError | |
autoload :ConfigurationFile | |
autoload :CurrentAttributes | |
autoload :Dependencies | |
autoload :DescendantsTracker | |
autoload :ExecutionContext | |
autoload :ExecutionWrapper | |
autoload :Executor | |
autoload :ErrorReporter | |
autoload :FileUpdateChecker | |
autoload :EventedFileUpdateChecker | |
autoload :ForkTracker | |
autoload :LogSubscriber | |
autoload :IsolatedExecutionState | |
autoload :Notifications | |
autoload :Reloader | |
autoload :PerThreadRegistry | |
autoload :SecureCompareRotator | |
eager_autoload do | |
autoload :BacktraceCleaner | |
autoload :ProxyObject | |
autoload :Benchmarkable | |
autoload :Cache | |
autoload :Callbacks | |
autoload :Configurable | |
autoload :Deprecation | |
autoload :Digest | |
autoload :Gzip | |
autoload :Inflector | |
autoload :JSON | |
autoload :JsonWithMarshalFallback | |
autoload :KeyGenerator | |
autoload :MessageEncryptor | |
autoload :MessageVerifier | |
autoload :Multibyte | |
autoload :NumberHelper | |
autoload :OptionMerger | |
autoload :OrderedHash | |
autoload :OrderedOptions | |
autoload :StringInquirer | |
autoload :EnvironmentInquirer | |
autoload :TaggedLogging | |
autoload :XmlMini | |
autoload :ArrayInquirer | |
end | |
autoload :Rescuable | |
autoload :SafeBuffer, "active_support/core_ext/string/output_safety" | |
autoload :TestCase | |
def self.eager_load! | |
super | |
NumberHelper.eager_load! | |
end | |
cattr_accessor :test_order # :nodoc: | |
cattr_accessor :test_parallelization_threshold, default: 50 # :nodoc: | |
singleton_class.attr_accessor :error_reporter # :nodoc: | |
def self.cache_format_version | |
Cache.format_version | |
end | |
def self.cache_format_version=(value) | |
Cache.format_version = value | |
end | |
def self.to_time_preserves_timezone | |
DateAndTime::Compatibility.preserve_timezone | |
end | |
def self.to_time_preserves_timezone=(value) | |
DateAndTime::Compatibility.preserve_timezone = value | |
end | |
def self.utc_to_local_returns_utc_offset_times | |
DateAndTime::Compatibility.utc_to_local_returns_utc_offset_times | |
end | |
def self.utc_to_local_returns_utc_offset_times=(value) | |
DateAndTime::Compatibility.utc_to_local_returns_utc_offset_times = value | |
end | |
end | |
autoload :I18n, "active_support/i18n" |
# frozen_string_literal: true | |
require "active_support/core_ext/object/acts_like" | |
class Time | |
# Duck-types as a Time-like class. See Object#acts_like?. | |
def acts_like_time? | |
true | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/date/acts_like" | |
require "active_support/core_ext/time/acts_like" | |
require "active_support/core_ext/date_time/acts_like" | |
require "active_support/core_ext/object/acts_like" | |
class ObjectTests < ActiveSupport::TestCase | |
class DuckTime | |
def acts_like_time? | |
true | |
end | |
end | |
def test_duck_typing | |
object = Object.new | |
time = Time.now | |
date = Date.today | |
dt = DateTime.new | |
duck = DuckTime.new | |
assert_not object.acts_like?(:time) | |
assert_not object.acts_like?(:date) | |
assert time.acts_like?(:time) | |
assert_not time.acts_like?(:date) | |
assert_not date.acts_like?(:time) | |
assert date.acts_like?(:date) | |
assert dt.acts_like?(:time) | |
assert dt.acts_like?(:date) | |
assert duck.acts_like?(:time) | |
assert_not duck.acts_like?(:date) | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
module ActiveRecord | |
module ConnectionAdapters | |
class AdapterLeasingTest < ActiveRecord::TestCase | |
class Pool < ConnectionPool | |
def insert_connection_for_test!(c) | |
synchronize do | |
adopt_connection(c) | |
@available.add c | |
end | |
end | |
end | |
def setup | |
@adapter = AbstractAdapter.new nil, nil | |
end | |
def test_in_use? | |
assert_not @adapter.in_use?, "adapter is not in use" | |
assert @adapter.lease, "lease adapter" | |
assert @adapter.in_use?, "adapter is in use" | |
end | |
def test_lease_twice | |
assert @adapter.lease, "should lease adapter" | |
assert_raises(ActiveRecordError) do | |
@adapter.lease | |
end | |
end | |
def test_expire_mutates_in_use | |
assert @adapter.lease, "lease adapter" | |
assert @adapter.in_use?, "adapter is in use" | |
@adapter.expire | |
assert_not @adapter.in_use?, "adapter is in use" | |
end | |
def test_close | |
db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", {}) | |
pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, db_config, :writing, :default) | |
pool = Pool.new(pool_config) | |
pool.insert_connection_for_test! @adapter | |
@adapter.pool = pool | |
# Make sure the pool marks the connection in use | |
assert_equal @adapter, pool.connection | |
assert_predicate @adapter, :in_use? | |
# Close should put the adapter back in the pool | |
@adapter.close | |
assert_not_predicate @adapter, :in_use? | |
assert_equal @adapter, pool.connection | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "support/connection_helper" | |
require "models/book" | |
require "models/post" | |
require "models/author" | |
require "models/event" | |
module ActiveRecord | |
class AdapterPreventWritesTest < ActiveRecord::TestCase | |
def setup | |
@connection = ActiveRecord::Base.connection | |
end | |
def test_preventing_writes_predicate | |
assert_not_predicate @connection, :preventing_writes? | |
ActiveRecord::Base.while_preventing_writes do | |
assert_predicate @connection, :preventing_writes? | |
end | |
assert_not_predicate @connection, :preventing_writes? | |
end | |
def test_errors_when_an_insert_query_is_called_while_preventing_writes | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_errors_when_an_update_query_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.update("UPDATE subscribers SET nick = '9989' WHERE nick = '138853948594'") | |
end | |
end | |
end | |
def test_errors_when_a_delete_query_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.delete("DELETE FROM subscribers WHERE nick = '138853948594'") | |
end | |
end | |
end | |
if current_adapter?(:PostgreSQLAdapter) | |
def test_doesnt_error_when_a_select_query_has_encoding_errors | |
ActiveRecord::Base.while_preventing_writes do | |
# Contrary to other adapters, Postgres will eagerly fail on encoding errors. | |
# But at least we can assert it fails in the client and not before when trying to | |
# match the query. | |
assert_raises ActiveRecord::StatementInvalid do | |
@connection.select_all("SELECT '\xC8'") | |
end | |
end | |
end | |
else | |
def test_doesnt_error_when_a_select_query_has_encoding_errors | |
ActiveRecord::Base.while_preventing_writes do | |
@connection.select_all("SELECT '\xC8'") | |
end | |
end | |
end | |
def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
ActiveRecord::Base.while_preventing_writes do | |
result = @connection.select_all("SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") | |
assert_equal 1, result.length | |
end | |
end | |
if ActiveRecord::Base.connection.supports_common_table_expressions? | |
def test_doesnt_error_when_a_read_query_with_a_cte_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
ActiveRecord::Base.while_preventing_writes do | |
result = @connection.select_all(<<~SQL) | |
WITH matching_subscribers AS (SELECT subscribers.* FROM subscribers WHERE nick = '138853948594') | |
SELECT * FROM matching_subscribers | |
SQL | |
assert_equal 1, result.length | |
end | |
end | |
end | |
def test_doesnt_error_when_a_select_query_starting_with_a_slash_star_comment_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
ActiveRecord::Base.while_preventing_writes do | |
result = @connection.select_all("/* some comment */ SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") | |
assert_equal 1, result.length | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_slash_star_comment_is_called_while_preventing_writes | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("/* some comment */ INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_doesnt_error_when_a_select_query_starting_with_double_dash_comments_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
ActiveRecord::Base.while_preventing_writes do | |
result = @connection.select_all("-- some comment\n-- comment about INSERT\nSELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") | |
assert_equal 1, result.length | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_double_dash_comment_is_called_while_preventing_writes | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("-- some comment\nINSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_slash_star_comment_containing_read_command_is_called_while_preventing_writes | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("/* SELECT */ INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_double_dash_comment_containing_read_command_is_called_while_preventing_writes | |
ActiveRecord::Base.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("-- SELECT\nINSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
end | |
class AdapterPreventWritesLegacyTest < ActiveRecord::TestCase | |
def setup | |
@old_value = ActiveRecord.legacy_connection_handling | |
ActiveRecord.legacy_connection_handling = true | |
@connection = ActiveRecord::Base.connection | |
@connection_handler = ActiveRecord::Base.connection_handler | |
end | |
def teardown | |
clean_up_legacy_connection_handlers | |
ActiveRecord.legacy_connection_handling = @old_value | |
end | |
def test_preventing_writes_predicate_legacy | |
assert_not_predicate @connection, :preventing_writes? | |
@connection_handler.while_preventing_writes do | |
assert_predicate @connection, :preventing_writes? | |
end | |
assert_not_predicate @connection, :preventing_writes? | |
end | |
def test_errors_when_an_insert_query_is_called_while_preventing_writes | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_errors_when_an_update_query_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.update("UPDATE subscribers SET nick = '9989' WHERE nick = '138853948594'") | |
end | |
end | |
end | |
def test_errors_when_a_delete_query_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.delete("DELETE FROM subscribers WHERE nick = '138853948594'") | |
end | |
end | |
end | |
def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
@connection_handler.while_preventing_writes do | |
result = @connection.select_all("SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") | |
assert_equal 1, result.length | |
end | |
end | |
if ActiveRecord::Base.connection.supports_common_table_expressions? | |
def test_doesnt_error_when_a_read_query_with_a_cte_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
@connection_handler.while_preventing_writes do | |
result = @connection.select_all(<<~SQL) | |
WITH matching_subscribers AS (SELECT subscribers.* FROM subscribers WHERE nick = '138853948594') | |
SELECT * FROM matching_subscribers | |
SQL | |
assert_equal 1, result.length | |
end | |
end | |
end | |
def test_doesnt_error_when_a_select_query_starting_with_a_slash_star_comment_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
@connection_handler.while_preventing_writes do | |
result = @connection.select_all("/* some comment */ SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") | |
assert_equal 1, result.length | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_slash_star_comment_is_called_while_preventing_writes | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("/* some comment */ INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_doesnt_error_when_a_select_query_starting_with_double_dash_comments_is_called_while_preventing_writes | |
@connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") | |
@connection_handler.while_preventing_writes do | |
result = @connection.select_all("-- some comment\n-- comment about INSERT\nSELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") | |
assert_equal 1, result.length | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_double_dash_comment_is_called_while_preventing_writes | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("-- some comment\nINSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_slash_star_comment_containing_read_command_is_called_while_preventing_writes | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("/* SELECT */ INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
def test_errors_when_an_insert_query_prefixed_by_a_double_dash_comment_containing_read_command_is_called_while_preventing_writes | |
@connection_handler.while_preventing_writes do | |
assert_raises(ActiveRecord::ReadOnlyError) do | |
@connection.insert("-- SELECT\nINSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
# :stopdoc: | |
module Type | |
class AdapterSpecificRegistry # :nodoc: | |
def initialize | |
@registrations = [] | |
end | |
def initialize_copy(other) | |
@registrations = @registrations.dup | |
end | |
def add_modifier(options, klass, **args) | |
registrations << DecorationRegistration.new(options, klass, **args) | |
end | |
def register(type_name, klass = nil, **options, &block) | |
unless block_given? | |
block = proc { |_, *args| klass.new(*args) } | |
block.ruby2_keywords if block.respond_to?(:ruby2_keywords) | |
end | |
registrations << Registration.new(type_name, block, **options) | |
end | |
def lookup(symbol, *args, **kwargs) | |
registration = find_registration(symbol, *args, **kwargs) | |
if registration | |
registration.call(self, symbol, *args, **kwargs) | |
else | |
raise ArgumentError, "Unknown type #{symbol.inspect}" | |
end | |
end | |
private | |
attr_reader :registrations | |
def find_registration(symbol, *args, **kwargs) | |
registrations | |
.select { |registration| registration.matches?(symbol, *args, **kwargs) } | |
.max | |
end | |
end | |
class Registration # :nodoc: | |
def initialize(name, block, adapter: nil, override: nil) | |
@name = name | |
@block = block | |
@adapter = adapter | |
@override = override | |
end | |
def call(_registry, *args, adapter: nil, **kwargs) | |
block.call(*args, **kwargs) | |
end | |
def matches?(type_name, *args, **kwargs) | |
type_name == name && matches_adapter?(**kwargs) | |
end | |
def <=>(other) | |
if conflicts_with?(other) | |
raise TypeConflictError.new("Type #{name} was registered for all | |
adapters, but shadows a native type with | |
the same name for #{other.adapter}".squish) | |
end | |
priority <=> other.priority | |
end | |
protected | |
attr_reader :name, :block, :adapter, :override | |
def priority | |
result = 0 | |
if adapter | |
result |= 1 | |
end | |
if override | |
result |= 2 | |
end | |
result | |
end | |
def priority_except_adapter | |
priority & 0b111111100 | |
end | |
private | |
def matches_adapter?(adapter: nil, **) | |
(self.adapter.nil? || adapter == self.adapter) | |
end | |
def conflicts_with?(other) | |
same_priority_except_adapter?(other) && | |
has_adapter_conflict?(other) | |
end | |
def same_priority_except_adapter?(other) | |
priority_except_adapter == other.priority_except_adapter | |
end | |
def has_adapter_conflict?(other) | |
(override.nil? && other.adapter) || | |
(adapter && other.override.nil?) | |
end | |
end | |
class DecorationRegistration < Registration # :nodoc: | |
def initialize(options, klass, adapter: nil) | |
@options = options | |
@klass = klass | |
@adapter = adapter | |
end | |
def call(registry, *args, **kwargs) | |
subtype = registry.lookup(*args, **kwargs.except(*options.keys)) | |
klass.new(subtype) | |
end | |
def matches?(*args, **kwargs) | |
matches_adapter?(**kwargs) && matches_options?(**kwargs) | |
end | |
def priority | |
super | 4 | |
end | |
private | |
attr_reader :options, :klass | |
def matches_options?(**kwargs) | |
options.all? do |key, value| | |
kwargs[key] == value | |
end | |
end | |
end | |
end | |
class TypeConflictError < StandardError # :nodoc: | |
end | |
# :startdoc: | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
module ActiveRecord | |
class AdapterSpecificRegistryTest < ActiveRecord::TestCase | |
test "a class can be registered for a symbol" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, ::String) | |
registry.register(:bar, ::Array) | |
assert_equal "", registry.lookup(:foo) | |
assert_equal [], registry.lookup(:bar) | |
end | |
test "a block can be registered" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo) do |*args| | |
[*args, "block for foo"] | |
end | |
registry.register(:bar) do |*args| | |
[*args, "block for bar"] | |
end | |
assert_equal [:foo, 1, "block for foo"], registry.lookup(:foo, 1) | |
assert_equal [:foo, 2, "block for foo"], registry.lookup(:foo, 2) | |
assert_equal [:bar, 1, 2, 3, "block for bar"], registry.lookup(:bar, 1, 2, 3) | |
end | |
test "filtering by adapter" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, String, adapter: :sqlite3) | |
registry.register(:foo, Array, adapter: :postgresql) | |
assert_equal "", registry.lookup(:foo, adapter: :sqlite3) | |
assert_equal [], registry.lookup(:foo, adapter: :postgresql) | |
end | |
test "an error is raised if both a generic and adapter specific type match" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, String) | |
registry.register(:foo, Array, adapter: :postgresql) | |
assert_raises TypeConflictError do | |
registry.lookup(:foo, adapter: :postgresql) | |
end | |
assert_equal "", registry.lookup(:foo, adapter: :sqlite3) | |
end | |
test "a generic type can explicitly override an adapter specific type" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, String, override: true) | |
registry.register(:foo, Array, adapter: :postgresql) | |
assert_equal "", registry.lookup(:foo, adapter: :postgresql) | |
assert_equal "", registry.lookup(:foo, adapter: :sqlite3) | |
end | |
test "a generic type can explicitly allow an adapter type to be used instead" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, String, override: false) | |
registry.register(:foo, Array, adapter: :postgresql) | |
assert_equal [], registry.lookup(:foo, adapter: :postgresql) | |
assert_equal "", registry.lookup(:foo, adapter: :sqlite3) | |
end | |
test "a reasonable error is given when no type is found" do | |
registry = Type::AdapterSpecificRegistry.new | |
e = assert_raises(ArgumentError) do | |
registry.lookup(:foo) | |
end | |
assert_equal "Unknown type :foo", e.message | |
end | |
test "construct args are passed to the type" do | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, type) | |
assert_equal type.new, registry.lookup(:foo) | |
assert_equal type.new(:ordered_arg), registry.lookup(:foo, :ordered_arg) | |
assert_equal type.new(keyword: :arg), registry.lookup(:foo, keyword: :arg) | |
assert_equal type.new(keyword: :arg), registry.lookup(:foo, keyword: :arg, adapter: :postgresql) | |
end | |
test "registering a modifier" do | |
decoration = Struct.new(:value) | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, String) | |
registry.register(:bar, Hash) | |
registry.add_modifier({ array: true }, decoration) | |
assert_equal decoration.new(""), registry.lookup(:foo, array: true) | |
assert_equal decoration.new({}), registry.lookup(:bar, array: true) | |
assert_equal "", registry.lookup(:foo) | |
end | |
test "registering multiple modifiers" do | |
decoration = Struct.new(:value) | |
other_decoration = Struct.new(:value) | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, String) | |
registry.add_modifier({ array: true }, decoration) | |
registry.add_modifier({ range: true }, other_decoration) | |
assert_equal "", registry.lookup(:foo) | |
assert_equal decoration.new(""), registry.lookup(:foo, array: true) | |
assert_equal other_decoration.new(""), registry.lookup(:foo, range: true) | |
assert_equal( | |
decoration.new(other_decoration.new("")), | |
registry.lookup(:foo, array: true, range: true) | |
) | |
end | |
test "registering adapter specific modifiers" do | |
decoration = Struct.new(:value) | |
registry = Type::AdapterSpecificRegistry.new | |
registry.register(:foo, type) | |
registry.add_modifier({ array: true }, decoration, adapter: :postgresql) | |
assert_equal( | |
decoration.new(type.new(keyword: :arg)), | |
registry.lookup(:foo, array: true, adapter: :postgresql, keyword: :arg) | |
) | |
assert_equal( | |
type.new(array: true), | |
registry.lookup(:foo, array: true, adapter: :sqlite3) | |
) | |
end | |
TYPE = Class.new do | |
attr_reader :args | |
def initialize(args = nil) | |
@args = args | |
end | |
def ==(other) self.args == other.args end | |
end | |
private def type; TYPE end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "support/connection_helper" | |
require "models/book" | |
require "models/post" | |
require "models/author" | |
require "models/event" | |
module ActiveRecord | |
class AdapterTest < ActiveRecord::TestCase | |
def setup | |
@connection = ActiveRecord::Base.connection | |
@connection.materialize_transactions | |
end | |
## | |
# PostgreSQL does not support null bytes in strings | |
unless current_adapter?(:PostgreSQLAdapter) || | |
(current_adapter?(:SQLite3Adapter) && !ActiveRecord::Base.connection.prepared_statements) | |
def test_update_prepared_statement | |
b = Book.create(name: "my \x00 book") | |
b.reload | |
assert_equal "my \x00 book", b.name | |
b.update(name: "my other \x00 book") | |
b.reload | |
assert_equal "my other \x00 book", b.name | |
end | |
end | |
def test_create_record_with_pk_as_zero | |
Book.create(id: 0) | |
assert_equal 0, Book.find(0).id | |
assert_nothing_raised { Book.destroy(0) } | |
end | |
def test_valid_column | |
@connection.native_database_types.each_key do |type| | |
assert @connection.valid_type?(type) | |
end | |
end | |
def test_invalid_column | |
assert_not @connection.valid_type?(:foobar) | |
end | |
def test_tables | |
tables = @connection.tables | |
assert_includes tables, "accounts" | |
assert_includes tables, "authors" | |
assert_includes tables, "tasks" | |
assert_includes tables, "topics" | |
end | |
def test_table_exists? | |
assert @connection.table_exists?("accounts") | |
assert @connection.table_exists?(:accounts) | |
assert_not @connection.table_exists?("nonexistingtable") | |
assert_not @connection.table_exists?("'") | |
assert_not @connection.table_exists?(nil) | |
end | |
def test_data_sources | |
data_sources = @connection.data_sources | |
assert_includes data_sources, "accounts" | |
assert_includes data_sources, "authors" | |
assert_includes data_sources, "tasks" | |
assert_includes data_sources, "topics" | |
end | |
def test_data_source_exists? | |
assert @connection.data_source_exists?("accounts") | |
assert @connection.data_source_exists?(:accounts) | |
assert_not @connection.data_source_exists?("nonexistingtable") | |
assert_not @connection.data_source_exists?("'") | |
assert_not @connection.data_source_exists?(nil) | |
end | |
def test_indexes | |
idx_name = "accounts_idx" | |
indexes = @connection.indexes("accounts") | |
assert_empty indexes | |
@connection.add_index :accounts, :firm_id, name: idx_name | |
indexes = @connection.indexes("accounts") | |
assert_equal "accounts", indexes.first.table | |
assert_equal idx_name, indexes.first.name | |
assert_not indexes.first.unique | |
assert_equal ["firm_id"], indexes.first.columns | |
ensure | |
@connection.remove_index(:accounts, name: idx_name) rescue nil | |
end | |
def test_remove_index_when_name_and_wrong_column_name_specified | |
index_name = "accounts_idx" | |
@connection.add_index :accounts, :firm_id, name: index_name | |
assert_raises ArgumentError do | |
@connection.remove_index :accounts, name: index_name, column: :wrong_column_name | |
end | |
ensure | |
@connection.remove_index(:accounts, name: index_name) | |
end | |
def test_remove_index_when_name_and_wrong_column_name_specified_positional_argument | |
index_name = "accounts_idx" | |
@connection.add_index :accounts, :firm_id, name: index_name | |
assert_raises ArgumentError do | |
@connection.remove_index :accounts, :wrong_column_name, name: index_name | |
end | |
ensure | |
@connection.remove_index(:accounts, name: index_name) | |
end | |
def test_current_database | |
if @connection.respond_to?(:current_database) | |
assert_equal ARTest.test_configuration_hashes["arunit"]["database"], @connection.current_database | |
end | |
end | |
def test_exec_query_returns_an_empty_result | |
result = @connection.exec_query "INSERT INTO subscribers(nick) VALUES('me')" | |
assert_instance_of(ActiveRecord::Result, result) | |
end | |
if current_adapter?(:Mysql2Adapter) | |
def test_charset | |
assert_not_nil @connection.charset | |
assert_not_equal "character_set_database", @connection.charset | |
assert_equal @connection.show_variable("character_set_database"), @connection.charset | |
end | |
def test_collation | |
assert_not_nil @connection.collation | |
assert_not_equal "collation_database", @connection.collation | |
assert_equal @connection.show_variable("collation_database"), @connection.collation | |
end | |
def test_show_nonexistent_variable_returns_nil | |
assert_nil @connection.show_variable("foo_bar_baz") | |
end | |
def test_not_specifying_database_name_for_cross_database_selects | |
assert_nothing_raised do | |
db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") | |
ActiveRecord::Base.establish_connection(db_config.configuration_hash.except(:database)) | |
config = ARTest.test_configuration_hashes | |
ActiveRecord::Base.connection.execute( | |
"SELECT #{config['arunit']['database']}.pirates.*, #{config['arunit2']['database']}.courses.* " \ | |
"FROM #{config['arunit']['database']}.pirates, #{config['arunit2']['database']}.courses" | |
) | |
end | |
ensure | |
ActiveRecord::Base.establish_connection :arunit | |
end | |
end | |
def test_table_alias | |
def @connection.test_table_alias_length() 10; end | |
class << @connection | |
alias_method :old_table_alias_length, :table_alias_length | |
alias_method :table_alias_length, :test_table_alias_length | |
end | |
assert_equal "posts", @connection.table_alias_for("posts") | |
assert_equal "posts_comm", @connection.table_alias_for("posts_comments") | |
assert_equal "dbo_posts", @connection.table_alias_for("dbo.posts") | |
class << @connection | |
remove_method :table_alias_length | |
alias_method :table_alias_length, :old_table_alias_length | |
end | |
end | |
def test_uniqueness_violations_are_translated_to_specific_exception | |
@connection.execute "INSERT INTO subscribers(nick) VALUES('me')" | |
error = assert_raises(ActiveRecord::RecordNotUnique) do | |
@connection.execute "INSERT INTO subscribers(nick) VALUES('me')" | |
end | |
assert_not_nil error.cause | |
end | |
def test_not_null_violations_are_translated_to_specific_exception | |
error = assert_raises(ActiveRecord::NotNullViolation) do | |
Post.create | |
end | |
assert_not_nil error.cause | |
end | |
unless current_adapter?(:SQLite3Adapter) | |
def test_value_limit_violations_are_translated_to_specific_exception | |
error = assert_raises(ActiveRecord::ValueTooLong) do | |
Event.create(title: "abcdefgh") | |
end | |
assert_not_nil error.cause | |
end | |
def test_numeric_value_out_of_ranges_are_translated_to_specific_exception | |
error = assert_raises(ActiveRecord::RangeError) do | |
Book.connection.create("INSERT INTO books(author_id) VALUES (9223372036854775808)") | |
end | |
assert_not_nil error.cause | |
end | |
end | |
def test_exceptions_from_notifications_are_not_translated | |
original_error = StandardError.new("This StandardError shouldn't get translated") | |
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") { raise original_error } | |
actual_error = assert_raises(StandardError) do | |
@connection.execute("SELECT * FROM posts") | |
end | |
assert_equal original_error, actual_error | |
ensure | |
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber | |
end | |
def test_database_related_exceptions_are_translated_to_statement_invalid | |
error = assert_raises(ActiveRecord::StatementInvalid) do | |
@connection.execute("This is a syntax error") | |
end | |
assert_instance_of ActiveRecord::StatementInvalid, error | |
assert_kind_of Exception, error.cause | |
end | |
def test_select_all_always_return_activerecord_result | |
result = @connection.select_all "SELECT * FROM posts" | |
assert result.is_a?(ActiveRecord::Result) | |
end | |
if ActiveRecord::Base.connection.prepared_statements | |
def test_select_all_insert_update_delete_with_casted_binds | |
binds = [Event.type_for_attribute("id").serialize(1)] | |
bind_param = Arel::Nodes::BindParam.new(nil) | |
id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds) | |
assert_equal 1, id | |
updated = @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_equal 1, updated | |
result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_equal({ "id" => 1, "title" => "foo" }, result.first) | |
deleted = @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_equal 1, deleted | |
result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_nil result.first | |
end | |
def test_select_all_insert_update_delete_with_binds | |
binds = [Relation::QueryAttribute.new("id", 1, Event.type_for_attribute("id"))] | |
bind_param = Arel::Nodes::BindParam.new(nil) | |
id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds) | |
assert_equal 1, id | |
updated = @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_equal 1, updated | |
result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_equal({ "id" => 1, "title" => "foo" }, result.first) | |
deleted = @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_equal 1, deleted | |
result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) | |
assert_nil result.first | |
end | |
end | |
def test_select_methods_passing_a_association_relation | |
author = Author.create!(name: "john") | |
Post.create!(author: author, title: "foo", body: "bar") | |
query = author.posts.where(title: "foo").select(:title) | |
assert_equal({ "title" => "foo" }, @connection.select_one(query)) | |
assert @connection.select_all(query).is_a?(ActiveRecord::Result) | |
assert_equal "foo", @connection.select_value(query) | |
assert_equal ["foo"], @connection.select_values(query) | |
end | |
def test_select_methods_passing_a_relation | |
Post.create!(title: "foo", body: "bar") | |
query = Post.where(title: "foo").select(:title) | |
assert_equal({ "title" => "foo" }, @connection.select_one(query)) | |
assert @connection.select_all(query).is_a?(ActiveRecord::Result) | |
assert_equal "foo", @connection.select_value(query) | |
assert_equal ["foo"], @connection.select_values(query) | |
end | |
test "type_to_sql returns a String for unmapped types" do | |
assert_equal "special_db_type", @connection.type_to_sql(:special_db_type) | |
end | |
end | |
class AdapterForeignKeyTest < ActiveRecord::TestCase | |
self.use_transactional_tests = false | |
fixtures :fk_test_has_pk | |
def setup | |
@connection = ActiveRecord::Base.connection | |
end | |
def test_foreign_key_violations_are_translated_to_specific_exception_with_validate_false | |
klass_has_fk = Class.new(ActiveRecord::Base) do | |
self.table_name = "fk_test_has_fk" | |
end | |
error = assert_raises(ActiveRecord::InvalidForeignKey) do | |
has_fk = klass_has_fk.new | |
has_fk.fk_id = 1231231231 | |
has_fk.save(validate: false) | |
end | |
assert_not_nil error.cause | |
end | |
def test_foreign_key_violations_on_insert_are_translated_to_specific_exception | |
error = assert_raises(ActiveRecord::InvalidForeignKey) do | |
insert_into_fk_test_has_fk | |
end | |
assert_not_nil error.cause | |
end | |
def test_foreign_key_violations_on_delete_are_translated_to_specific_exception | |
insert_into_fk_test_has_fk fk_id: 1 | |
error = assert_raises(ActiveRecord::InvalidForeignKey) do | |
@connection.execute "DELETE FROM fk_test_has_pk WHERE pk_id = 1" | |
end | |
assert_not_nil error.cause | |
end | |
def test_disable_referential_integrity | |
assert_nothing_raised do | |
@connection.disable_referential_integrity do | |
insert_into_fk_test_has_fk | |
# should delete created record as otherwise disable_referential_integrity will try to enable constraints | |
# after executed block and will fail (at least on Oracle) | |
@connection.execute "DELETE FROM fk_test_has_fk" | |
end | |
end | |
end | |
private | |
def insert_into_fk_test_has_fk(fk_id: 0) | |
# Oracle adapter uses prefetched primary key values from sequence and passes them to connection adapter insert method | |
if @connection.prefetch_primary_key? | |
id_value = @connection.next_sequence_value(@connection.default_sequence_name("fk_test_has_fk", "id")) | |
@connection.execute "INSERT INTO fk_test_has_fk (id,fk_id) VALUES (#{id_value},#{fk_id})" | |
else | |
@connection.execute "INSERT INTO fk_test_has_fk (fk_id) VALUES (#{fk_id})" | |
end | |
end | |
end | |
class AdapterTestWithoutTransaction < ActiveRecord::TestCase | |
self.use_transactional_tests = false | |
fixtures :posts, :authors, :author_addresses | |
def setup | |
@connection = ActiveRecord::Base.connection | |
end | |
unless in_memory_db? | |
test "reconnect after a disconnect" do | |
assert_predicate @connection, :active? | |
@connection.disconnect! | |
assert_not_predicate @connection, :active? | |
@connection.reconnect! | |
assert_predicate @connection, :active? | |
end | |
test "materialized transaction state is reset after a reconnect" do | |
@connection.begin_transaction | |
assert_predicate @connection, :transaction_open? | |
@connection.materialize_transactions | |
assert raw_transaction_open?(@connection) | |
@connection.reconnect! | |
assert_not_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
end | |
test "materialized transaction state can be restored after a reconnect" do | |
@connection.begin_transaction | |
assert_predicate @connection, :transaction_open? | |
# +materialize_transactions+ currently automatically dirties the | |
# connection, which would make it unrestorable | |
@connection.transaction_manager.stub(:dirty_current_transaction, nil) do | |
@connection.materialize_transactions | |
end | |
assert raw_transaction_open?(@connection) | |
@connection.reconnect!(restore_transactions: true) | |
assert_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
ensure | |
@connection.reconnect! | |
assert_not_predicate @connection, :transaction_open? | |
end | |
test "materialized transaction state is reset after a disconnect" do | |
@connection.begin_transaction | |
assert_predicate @connection, :transaction_open? | |
@connection.materialize_transactions | |
assert raw_transaction_open?(@connection) | |
@connection.disconnect! | |
assert_not_predicate @connection, :transaction_open? | |
ensure | |
@connection.reconnect! | |
assert_not raw_transaction_open?(@connection) | |
end | |
test "unmaterialized transaction state is reset after a reconnect" do | |
@connection.begin_transaction | |
assert_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
@connection.reconnect! | |
assert_not_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
@connection.materialize_transactions | |
assert_not raw_transaction_open?(@connection) | |
end | |
test "unmaterialized transaction state can be restored after a reconnect" do | |
@connection.begin_transaction | |
assert_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
@connection.reconnect!(restore_transactions: true) | |
assert_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
@connection.materialize_transactions | |
assert raw_transaction_open?(@connection) | |
ensure | |
@connection.reconnect! | |
assert_not_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
end | |
test "unmaterialized transaction state is reset after a disconnect" do | |
@connection.begin_transaction | |
assert_predicate @connection, :transaction_open? | |
assert_not raw_transaction_open?(@connection) | |
@connection.disconnect! | |
assert_not_predicate @connection, :transaction_open? | |
ensure | |
@connection.reconnect! | |
assert_not raw_transaction_open?(@connection) | |
@connection.materialize_transactions | |
assert_not raw_transaction_open?(@connection) | |
end | |
end | |
def test_create_with_query_cache | |
@connection.enable_query_cache! | |
count = Post.count | |
@connection.create("INSERT INTO posts(title, body) VALUES ('', '')") | |
assert_equal count + 1, Post.count | |
ensure | |
reset_fixtures("posts") | |
@connection.disable_query_cache! | |
end | |
def test_truncate | |
assert_operator Post.count, :>, 0 | |
@connection.truncate("posts") | |
assert_equal 0, Post.count | |
ensure | |
reset_fixtures("posts") | |
end | |
def test_truncate_with_query_cache | |
@connection.enable_query_cache! | |
assert_operator Post.count, :>, 0 | |
@connection.truncate("posts") | |
assert_equal 0, Post.count | |
ensure | |
reset_fixtures("posts") | |
@connection.disable_query_cache! | |
end | |
def test_truncate_tables | |
assert_operator Post.count, :>, 0 | |
assert_operator Author.count, :>, 0 | |
assert_operator AuthorAddress.count, :>, 0 | |
@connection.truncate_tables("author_addresses", "authors", "posts") | |
assert_equal 0, Post.count | |
assert_equal 0, Author.count | |
assert_equal 0, AuthorAddress.count | |
ensure | |
reset_fixtures("posts", "authors", "author_addresses") | |
end | |
def test_truncate_tables_with_query_cache | |
@connection.enable_query_cache! | |
assert_operator Post.count, :>, 0 | |
assert_operator Author.count, :>, 0 | |
assert_operator AuthorAddress.count, :>, 0 | |
@connection.truncate_tables("author_addresses", "authors", "posts") | |
assert_equal 0, Post.count | |
assert_equal 0, Author.count | |
assert_equal 0, AuthorAddress.count | |
ensure | |
reset_fixtures("posts", "authors", "author_addresses") | |
@connection.disable_query_cache! | |
end | |
# test resetting sequences in odd tables in PostgreSQL | |
if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!) | |
require "models/movie" | |
require "models/subscriber" | |
def test_reset_empty_table_with_custom_pk | |
Movie.delete_all | |
Movie.connection.reset_pk_sequence! "movies" | |
assert_equal 1, Movie.create(name: "fight club").id | |
end | |
def test_reset_table_with_non_integer_pk | |
Subscriber.delete_all | |
Subscriber.connection.reset_pk_sequence! "subscribers" | |
sub = Subscriber.new(name: "robert drake") | |
sub.id = "bob drake" | |
assert_nothing_raised { sub.save! } | |
end | |
end | |
private | |
def raw_transaction_open?(connection) | |
case connection.class::ADAPTER_NAME | |
when "PostgreSQL" | |
connection.instance_variable_get(:@raw_connection).transaction_status == ::PG::PQTRANS_INTRANS | |
when "Mysql2" | |
begin | |
connection.instance_variable_get(:@raw_connection).query("SAVEPOINT transaction_test") | |
connection.instance_variable_get(:@raw_connection).query("RELEASE SAVEPOINT transaction_test") | |
true | |
rescue | |
false | |
end | |
when "SQLite" | |
begin | |
connection.instance_variable_get(:@raw_connection).transaction { nil } | |
false | |
rescue | |
true | |
end | |
else | |
skip | |
end | |
end | |
def reset_fixtures(*fixture_names) | |
ActiveRecord::FixtureSet.reset_cache | |
fixture_names.each do |fixture_name| | |
ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, fixture_name) | |
end | |
end | |
end | |
end | |
if ActiveRecord::Base.connection.supports_advisory_locks? | |
class AdvisoryLocksEnabledTest < ActiveRecord::TestCase | |
include ConnectionHelper | |
def test_advisory_locks_enabled? | |
assert ActiveRecord::Base.connection.advisory_locks_enabled? | |
run_without_connection do |orig_connection| | |
ActiveRecord::Base.establish_connection( | |
orig_connection.merge(advisory_locks: false) | |
) | |
assert_not ActiveRecord::Base.connection.advisory_locks_enabled? | |
ActiveRecord::Base.establish_connection( | |
orig_connection.merge(advisory_locks: true) | |
) | |
assert ActiveRecord::Base.connection.advisory_locks_enabled? | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module Admin | |
def self.table_name_prefix | |
"admin_" | |
end | |
end |
# frozen_string_literal: true | |
require "openssl" | |
module ActiveRecord | |
module Encryption | |
class Cipher | |
# A 256-GCM cipher. | |
# | |
# By default it will use random initialization vectors. For deterministic encryption, it will use a SHA-256 hash of | |
# the text to encrypt and the secret. | |
# | |
# See +Encryptor+ | |
class Aes256Gcm | |
CIPHER_TYPE = "aes-256-gcm" | |
class << self | |
def key_length | |
OpenSSL::Cipher.new(CIPHER_TYPE).key_len | |
end | |
def iv_length | |
OpenSSL::Cipher.new(CIPHER_TYPE).iv_len | |
end | |
end | |
# When iv not provided, it will generate a random iv on each encryption operation (default and | |
# recommended operation) | |
def initialize(secret, deterministic: false) | |
@secret = secret | |
@deterministic = deterministic | |
end | |
def encrypt(clear_text) | |
# This code is extracted from +ActiveSupport::MessageEncryptor+. Not using it directly because we want to control | |
# the message format and only serialize things once at the +ActiveRecord::Encryption::Message+ level. Also, this | |
# cipher is prepared to deal with deterministic/non deterministic encryption modes. | |
cipher = OpenSSL::Cipher.new(CIPHER_TYPE) | |
cipher.encrypt | |
cipher.key = @secret | |
iv = generate_iv(cipher, clear_text) | |
cipher.iv = iv | |
encrypted_data = clear_text.empty? ? clear_text.dup : cipher.update(clear_text) | |
encrypted_data << cipher.final | |
ActiveRecord::Encryption::Message.new(payload: encrypted_data).tap do |message| | |
message.headers.iv = iv | |
message.headers.auth_tag = cipher.auth_tag | |
end | |
end | |
def decrypt(encrypted_message) | |
encrypted_data = encrypted_message.payload | |
iv = encrypted_message.headers.iv | |
auth_tag = encrypted_message.headers.auth_tag | |
# Currently the OpenSSL bindings do not raise an error if auth_tag is | |
# truncated, which would allow an attacker to easily forge it. See | |
# https://github.com/ruby/openssl/issues/63 | |
raise ActiveRecord::Encryption::Errors::EncryptedContentIntegrity if auth_tag.nil? || auth_tag.bytes.length != 16 | |
cipher = OpenSSL::Cipher.new(CIPHER_TYPE) | |
cipher.decrypt | |
cipher.key = @secret | |
cipher.iv = iv | |
cipher.auth_tag = auth_tag | |
cipher.auth_data = "" | |
decrypted_data = encrypted_data.empty? ? encrypted_data : cipher.update(encrypted_data) | |
decrypted_data << cipher.final | |
decrypted_data | |
rescue OpenSSL::Cipher::CipherError, TypeError, ArgumentError | |
raise ActiveRecord::Encryption::Errors::Decryption | |
end | |
private | |
def generate_iv(cipher, clear_text) | |
if @deterministic | |
generate_deterministic_iv(clear_text) | |
else | |
cipher.random_iv | |
end | |
end | |
def generate_deterministic_iv(clear_text) | |
OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @secret, clear_text)[0, ActiveRecord::Encryption.cipher.iv_length] | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/encryption/helper" | |
class ActiveRecord::Encryption::Aes256GcmTest < ActiveRecord::EncryptionTestCase | |
setup do | |
@key = ActiveRecord::Encryption.key_generator.generate_random_key length: ActiveRecord::Encryption::Cipher::Aes256Gcm.key_length | |
@cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key) | |
end | |
test "encrypts strings" do | |
assert_cipher_encrypts(@cipher, "Some clear text") | |
end | |
test "works with empty strings" do | |
assert_cipher_encrypts(@cipher, "") | |
end | |
test "uses non-deterministic encryption by default" do | |
assert_not_equal @cipher.encrypt("Some text").payload, @cipher.encrypt("Some text").payload | |
end | |
test "in deterministic mode, it generates the same ciphertext for the same inputs" do | |
cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key, deterministic: true) | |
assert_cipher_encrypts(cipher, "Some clear text") | |
assert_equal cipher.encrypt("Some text").payload, cipher.encrypt("Some text").payload | |
assert_not_equal cipher.encrypt("Some text").payload, cipher.encrypt("Some other text").payload | |
end | |
test "it generates different ivs for different ciphertexts" do | |
cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key, deterministic: true) | |
assert_equal cipher.encrypt("Some text").headers.iv, cipher.encrypt("Some text").headers.iv | |
assert_not_equal cipher.encrypt("Some text").headers.iv, cipher.encrypt("Some other text").headers.iv | |
end | |
private | |
def assert_cipher_encrypts(cipher, content_to_encrypt) | |
encrypted_content = cipher.encrypt(content_to_encrypt) | |
assert_not_equal content_to_encrypt, encrypted_content | |
assert_equal content_to_encrypt, cipher.decrypt(encrypted_content) | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../abstract_unit" | |
module OtherAfterTeardown | |
def after_teardown | |
super | |
@witness = true | |
end | |
end | |
class AfterTeardownTest < ActiveSupport::TestCase | |
include OtherAfterTeardown | |
attr_writer :witness | |
MyError = Class.new(StandardError) | |
teardown do | |
raise MyError, "Test raises an error, all after_teardown should still get called" | |
end | |
def after_teardown | |
assert_changes -> { failures.count }, from: 0, to: 1 do | |
super | |
end | |
assert_equal true, @witness | |
failures.clear | |
end | |
def test_teardown_raise_but_all_after_teardown_method_are_called | |
assert true | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
# See ActiveRecord::Aggregations::ClassMethods for documentation | |
module Aggregations | |
def initialize_dup(*) # :nodoc: | |
@aggregation_cache = {} | |
super | |
end | |
def reload(*) # :nodoc: | |
clear_aggregation_cache | |
super | |
end | |
private | |
def clear_aggregation_cache | |
@aggregation_cache.clear if persisted? | |
end | |
def init_internals | |
@aggregation_cache = {} | |
super | |
end | |
# Active Record implements aggregation through a macro-like class method called #composed_of | |
# for representing attributes as value objects. It expresses relationships like "Account [is] | |
# composed of Money [among other things]" or "Person [is] composed of [an] address". Each call | |
# to the macro adds a description of how the value objects are created from the attributes of | |
# the entity object (when the entity is initialized either as a new object or from finding an | |
# existing object) and how it can be turned back into attributes (when the entity is saved to | |
# the database). | |
# | |
# class Customer < ActiveRecord::Base | |
# composed_of :balance, class_name: "Money", mapping: %w(balance amount) | |
# composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ] | |
# end | |
# | |
# The customer class now has the following methods to manipulate the value objects: | |
# * <tt>Customer#balance, Customer#balance=(money)</tt> | |
# * <tt>Customer#address, Customer#address=(address)</tt> | |
# | |
# These methods will operate with value objects like the ones described below: | |
# | |
# class Money | |
# include Comparable | |
# attr_reader :amount, :currency | |
# EXCHANGE_RATES = { "USD_TO_DKK" => 6 } | |
# | |
# def initialize(amount, currency = "USD") | |
# @amount, @currency = amount, currency | |
# end | |
# | |
# def exchange_to(other_currency) | |
# exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor | |
# Money.new(exchanged_amount, other_currency) | |
# end | |
# | |
# def ==(other_money) | |
# amount == other_money.amount && currency == other_money.currency | |
# end | |
# | |
# def <=>(other_money) | |
# if currency == other_money.currency | |
# amount <=> other_money.amount | |
# else | |
# amount <=> other_money.exchange_to(currency).amount | |
# end | |
# end | |
# end | |
# | |
# class Address | |
# attr_reader :street, :city | |
# def initialize(street, city) | |
# @street, @city = street, city | |
# end | |
# | |
# def close_to?(other_address) | |
# city == other_address.city | |
# end | |
# | |
# def ==(other_address) | |
# city == other_address.city && street == other_address.street | |
# end | |
# end | |
# | |
# Now it's possible to access attributes from the database through the value objects instead. If | |
# you choose to name the composition the same as the attribute's name, it will be the only way to | |
# access that attribute. That's the case with our +balance+ attribute. You interact with the value | |
# objects just like you would with any other attribute: | |
# | |
# customer.balance = Money.new(20) # sets the Money value object and the attribute | |
# customer.balance # => Money value object | |
# customer.balance.exchange_to("DKK") # => Money.new(120, "DKK") | |
# customer.balance > Money.new(10) # => true | |
# customer.balance == Money.new(20) # => true | |
# customer.balance < Money.new(5) # => false | |
# | |
# Value objects can also be composed of multiple attributes, such as the case of Address. The order | |
# of the mappings will determine the order of the parameters. | |
# | |
# customer.address_street = "Hyancintvej" | |
# customer.address_city = "Copenhagen" | |
# customer.address # => Address.new("Hyancintvej", "Copenhagen") | |
# | |
# customer.address = Address.new("May Street", "Chicago") | |
# customer.address_street # => "May Street" | |
# customer.address_city # => "Chicago" | |
# | |
# == Writing value objects | |
# | |
# Value objects are immutable and interchangeable objects that represent a given value, such as | |
# a Money object representing $5. Two Money objects both representing $5 should be equal (through | |
# methods such as <tt>==</tt> and <tt><=></tt> from Comparable if ranking makes sense). This is | |
# unlike entity objects where equality is determined by identity. An entity class such as Customer can | |
# easily have two different objects that both have an address on Hyancintvej. Entity identity is | |
# determined by object or relational unique identifiers (such as primary keys). Normal | |
# ActiveRecord::Base classes are entity objects. | |
# | |
# It's also important to treat the value objects as immutable. Don't allow the Money object to have | |
# its amount changed after creation. Create a new Money object with the new value instead. The | |
# <tt>Money#exchange_to</tt> method is an example of this. It returns a new value object instead of changing | |
# its own values. Active Record won't persist value objects that have been changed through means | |
# other than the writer method. | |
# | |
# The immutable requirement is enforced by Active Record by freezing any object assigned as a value | |
# object. Attempting to change it afterwards will result in a +RuntimeError+. | |
# | |
# Read more about value objects on http://c2.com/cgi/wiki?ValueObject and on the dangers of not | |
# keeping value objects immutable on http://c2.com/cgi/wiki?ValueObjectsShouldBeImmutable | |
# | |
# == Custom constructors and converters | |
# | |
# By default value objects are initialized by calling the <tt>new</tt> constructor of the value | |
# class passing each of the mapped attributes, in the order specified by the <tt>:mapping</tt> | |
# option, as arguments. If the value class doesn't support this convention then #composed_of allows | |
# a custom constructor to be specified. | |
# | |
# When a new value is assigned to the value object, the default assumption is that the new value | |
# is an instance of the value class. Specifying a custom converter allows the new value to be automatically | |
# converted to an instance of value class if necessary. | |
# | |
# For example, the +NetworkResource+ model has +network_address+ and +cidr_range+ attributes that should be | |
# aggregated using the +NetAddr::CIDR+ value class (https://www.rubydoc.info/gems/netaddr/1.5.0/NetAddr/CIDR). | |
# The constructor for the value class is called +create+ and it expects a CIDR address string as a parameter. | |
# New values can be assigned to the value object using either another +NetAddr::CIDR+ object, a string | |
# or an array. The <tt>:constructor</tt> and <tt>:converter</tt> options can be used to meet | |
# these requirements: | |
# | |
# class NetworkResource < ActiveRecord::Base | |
# composed_of :cidr, | |
# class_name: 'NetAddr::CIDR', | |
# mapping: [ %w(network_address network), %w(cidr_range bits) ], | |
# allow_nil: true, | |
# constructor: Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") }, | |
# converter: Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) } | |
# end | |
# | |
# # This calls the :constructor | |
# network_resource = NetworkResource.new(network_address: '192.168.0.1', cidr_range: 24) | |
# | |
# # These assignments will both use the :converter | |
# network_resource.cidr = [ '192.168.2.1', 8 ] | |
# network_resource.cidr = '192.168.0.1/24' | |
# | |
# # This assignment won't use the :converter as the value is already an instance of the value class | |
# network_resource.cidr = NetAddr::CIDR.create('192.168.2.1/8') | |
# | |
# # Saving and then reloading will use the :constructor on reload | |
# network_resource.save | |
# network_resource.reload | |
# | |
# == Finding records by a value object | |
# | |
# Once a #composed_of relationship is specified for a model, records can be loaded from the database | |
# by specifying an instance of the value object in the conditions hash. The following example | |
# finds all customers with +address_street+ equal to "May Street" and +address_city+ equal to "Chicago": | |
# | |
# Customer.where(address: Address.new("May Street", "Chicago")) | |
# | |
module ClassMethods | |
# Adds reader and writer methods for manipulating a value object: | |
# <tt>composed_of :address</tt> adds <tt>address</tt> and <tt>address=(new_address)</tt> methods. | |
# | |
# Options are: | |
# * <tt>:class_name</tt> - Specifies the class name of the association. Use it only if that name | |
# can't be inferred from the part id. So <tt>composed_of :address</tt> will by default be linked | |
# to the Address class, but if the real class name is +CompanyAddress+, you'll have to specify it | |
# with this option. | |
# * <tt>:mapping</tt> - Specifies the mapping of entity attributes to attributes of the value | |
# object. Each mapping is represented as an array where the first item is the name of the | |
# entity attribute and the second item is the name of the attribute in the value object. The | |
# order in which mappings are defined determines the order in which attributes are sent to the | |
# value class constructor. | |
# * <tt>:allow_nil</tt> - Specifies that the value object will not be instantiated when all mapped | |
# attributes are +nil+. Setting the value object to +nil+ has the effect of writing +nil+ to all | |
# mapped attributes. | |
# This defaults to +false+. | |
# * <tt>:constructor</tt> - A symbol specifying the name of the constructor method or a Proc that | |
# is called to initialize the value object. The constructor is passed all of the mapped attributes, | |
# in the order that they are defined in the <tt>:mapping option</tt>, as arguments and uses them | |
# to instantiate a <tt>:class_name</tt> object. | |
# The default is <tt>:new</tt>. | |
# * <tt>:converter</tt> - A symbol specifying the name of a class method of <tt>:class_name</tt> | |
# or a Proc that is called when a new value is assigned to the value object. The converter is | |
# passed the single value that is used in the assignment and is only called if the new value is | |
# not an instance of <tt>:class_name</tt>. If <tt>:allow_nil</tt> is set to true, the converter | |
# can return +nil+ to skip the assignment. | |
# | |
# Option examples: | |
# composed_of :temperature, mapping: %w(reading celsius) | |
# composed_of :balance, class_name: "Money", mapping: %w(balance amount) | |
# composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ] | |
# composed_of :gps_location | |
# composed_of :gps_location, allow_nil: true | |
# composed_of :ip_address, | |
# class_name: 'IPAddr', | |
# mapping: %w(ip to_i), | |
# constructor: Proc.new { |ip| IPAddr.new(ip, Socket::AF_INET) }, | |
# converter: Proc.new { |ip| ip.is_a?(Integer) ? IPAddr.new(ip, Socket::AF_INET) : IPAddr.new(ip.to_s) } | |
# | |
def composed_of(part_id, options = {}) | |
options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter) | |
unless self < Aggregations | |
include Aggregations | |
end | |
name = part_id.id2name | |
class_name = options[:class_name] || name.camelize | |
mapping = options[:mapping] || [ name, name ] | |
mapping = [ mapping ] unless mapping.first.is_a?(Array) | |
allow_nil = options[:allow_nil] || false | |
constructor = options[:constructor] || :new | |
converter = options[:converter] | |
reader_method(name, class_name, mapping, allow_nil, constructor) | |
writer_method(name, class_name, mapping, allow_nil, converter) | |
reflection = ActiveRecord::Reflection.create(:composed_of, part_id, nil, options, self) | |
Reflection.add_aggregate_reflection self, part_id, reflection | |
end | |
private | |
def reader_method(name, class_name, mapping, allow_nil, constructor) | |
define_method(name) do | |
if @aggregation_cache[name].nil? && (!allow_nil || mapping.any? { |key, _| !read_attribute(key).nil? }) | |
attrs = mapping.collect { |key, _| read_attribute(key) } | |
object = constructor.respond_to?(:call) ? | |
constructor.call(*attrs) : | |
class_name.constantize.send(constructor, *attrs) | |
@aggregation_cache[name] = object | |
end | |
@aggregation_cache[name] | |
end | |
end | |
def writer_method(name, class_name, mapping, allow_nil, converter) | |
define_method("#{name}=") do |part| | |
klass = class_name.constantize | |
unless part.is_a?(klass) || converter.nil? || part.nil? | |
part = converter.respond_to?(:call) ? converter.call(part) : klass.send(converter, part) | |
end | |
hash_from_multiparameter_assignment = part.is_a?(Hash) && | |
part.keys.all?(Integer) | |
if hash_from_multiparameter_assignment | |
raise ArgumentError unless part.size == part.each_key.max | |
part = klass.new(*part.sort.map(&:last)) | |
end | |
if part.nil? && allow_nil | |
mapping.each { |key, _| write_attribute(key, nil) } | |
@aggregation_cache[name] = nil | |
else | |
mapping.each { |key, value| write_attribute(key, part.send(value)) } | |
@aggregation_cache[name] = part.freeze | |
end | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/customer" | |
class AggregationsTest < ActiveRecord::TestCase | |
fixtures :customers | |
def test_find_single_value_object | |
assert_equal 50, customers(:david).balance.amount | |
assert_kind_of Money, customers(:david).balance | |
assert_equal 300, customers(:david).balance.exchange_to("DKK").amount | |
end | |
def test_find_multiple_value_object | |
assert_equal customers(:david).address_street, customers(:david).address.street | |
assert( | |
customers(:david).address.close_to?(Address.new("Different Street", customers(:david).address_city, customers(:david).address_country)) | |
) | |
end | |
def test_change_single_value_object | |
customers(:david).balance = Money.new(100) | |
customers(:david).save | |
assert_equal 100, customers(:david).reload.balance.amount | |
end | |
def test_immutable_value_objects | |
customers(:david).balance = Money.new(100) | |
assert_raise(FrozenError) { customers(:david).balance.instance_eval { @amount = 20 } } | |
end | |
def test_inferred_mapping | |
assert_equal "35.544623640962634", customers(:david).gps_location.latitude | |
assert_equal "-105.9309951055148", customers(:david).gps_location.longitude | |
customers(:david).gps_location = GpsLocation.new("39x-110") | |
assert_equal "39", customers(:david).gps_location.latitude | |
assert_equal "-110", customers(:david).gps_location.longitude | |
customers(:david).save | |
customers(:david).reload | |
assert_equal "39", customers(:david).gps_location.latitude | |
assert_equal "-110", customers(:david).gps_location.longitude | |
end | |
def test_reloaded_instance_refreshes_aggregations | |
assert_equal "35.544623640962634", customers(:david).gps_location.latitude | |
assert_equal "-105.9309951055148", customers(:david).gps_location.longitude | |
Customer.update_all("gps_location = '24x113'") | |
customers(:david).reload | |
assert_equal "24x113", customers(:david)["gps_location"] | |
assert_equal GpsLocation.new("24x113"), customers(:david).gps_location | |
end | |
def test_gps_equality | |
assert_equal GpsLocation.new("39x110"), GpsLocation.new("39x110") | |
end | |
def test_gps_inequality | |
assert_not_equal GpsLocation.new("39x110"), GpsLocation.new("39x111") | |
end | |
def test_allow_nil_gps_is_nil | |
assert_nil customers(:zaphod).gps_location | |
end | |
def test_allow_nil_gps_set_to_nil | |
customers(:david).gps_location = nil | |
customers(:david).save | |
customers(:david).reload | |
assert_nil customers(:david).gps_location | |
end | |
def test_allow_nil_set_address_attributes_to_nil | |
customers(:zaphod).address = nil | |
assert_nil customers(:zaphod).attributes[:address_street] | |
assert_nil customers(:zaphod).attributes[:address_city] | |
assert_nil customers(:zaphod).attributes[:address_country] | |
end | |
def test_allow_nil_address_set_to_nil | |
customers(:zaphod).address = nil | |
customers(:zaphod).save | |
customers(:zaphod).reload | |
assert_nil customers(:zaphod).address | |
end | |
def test_nil_raises_error_when_allow_nil_is_false | |
assert_raise(NoMethodError) { customers(:david).balance = nil } | |
end | |
def test_allow_nil_address_loaded_when_only_some_attributes_are_nil | |
customers(:zaphod).address_street = nil | |
customers(:zaphod).save | |
customers(:zaphod).reload | |
assert_kind_of Address, customers(:zaphod).address | |
assert_nil customers(:zaphod).address.street | |
end | |
def test_nil_assignment_results_in_nil | |
customers(:david).gps_location = GpsLocation.new("39x111") | |
assert_not_nil customers(:david).gps_location | |
customers(:david).gps_location = nil | |
assert_nil customers(:david).gps_location | |
end | |
def test_nil_return_from_converter_is_respected_when_allow_nil_is_true | |
customers(:david).non_blank_gps_location = "" | |
customers(:david).save | |
customers(:david).reload | |
assert_nil customers(:david).non_blank_gps_location | |
ensure | |
Customer.gps_conversion_was_run = nil | |
end | |
def test_nil_return_from_converter_results_in_failure_when_allow_nil_is_false | |
assert_raises(NoMethodError) do | |
customers(:barney).gps_location = "" | |
end | |
end | |
def test_do_not_run_the_converter_when_nil_was_set | |
customers(:david).non_blank_gps_location = nil | |
assert_nil Customer.gps_conversion_was_run | |
end | |
def test_custom_constructor | |
assert_equal "Barney GUMBLE", customers(:barney).fullname.to_s | |
assert_kind_of Fullname, customers(:barney).fullname | |
end | |
def test_custom_converter | |
customers(:barney).fullname = "Barnoit Gumbleau" | |
assert_equal "Barnoit GUMBLEAU", customers(:barney).fullname.to_s | |
assert_kind_of Fullname, customers(:barney).fullname | |
end | |
def test_assigning_hash_to_custom_converter | |
customers(:barney).fullname = { first: "Barney", last: "Stinson" } | |
assert_equal "Barney STINSON", customers(:barney).name | |
end | |
def test_assigning_hash_without_custom_converter | |
customers(:barney).fullname_no_converter = { first: "Barney", last: "Stinson" } | |
assert_equal({ first: "Barney", last: "Stinson" }.to_s, customers(:barney).name) | |
end | |
end | |
class OverridingAggregationsTest < ActiveRecord::TestCase | |
class DifferentName; end | |
class Person < ActiveRecord::Base | |
composed_of :composed_of, mapping: %w(person_first_name first_name) | |
end | |
class DifferentPerson < Person | |
composed_of :composed_of, class_name: "DifferentName", mapping: %w(different_person_first_name first_name) | |
end | |
def test_composed_of_aggregation_redefinition_reflections_should_differ_and_not_inherited | |
assert_not_equal Person.reflect_on_aggregation(:composed_of), | |
DifferentPerson.reflect_on_aggregation(:composed_of) | |
end | |
end |
# frozen_string_literal: true | |
class Aircraft < ActiveRecord::Base | |
self.pluralize_table_names = false | |
has_many :engines, foreign_key: "car_id" | |
has_many :wheels, as: :wheelable | |
end |
# frozen_string_literal: true | |
module Arel # :nodoc: all | |
module AliasPredication | |
def as(other) | |
Nodes::As.new self, Nodes::SqlLiteral.new(other) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/string/conversions" | |
module ActiveRecord | |
module Associations | |
# Keeps track of table aliases for ActiveRecord::Associations::JoinDependency | |
class AliasTracker # :nodoc: | |
def self.create(connection, initial_table, joins, aliases = nil) | |
if joins.empty? | |
aliases ||= Hash.new(0) | |
elsif aliases | |
default_proc = aliases.default_proc || proc { 0 } | |
aliases.default_proc = proc { |h, k| | |
h[k] = initial_count_for(connection, k, joins) + default_proc.call(h, k) | |
} | |
else | |
aliases = Hash.new { |h, k| | |
h[k] = initial_count_for(connection, k, joins) | |
} | |
end | |
aliases[initial_table] = 1 | |
new(connection, aliases) | |
end | |
def self.initial_count_for(connection, name, table_joins) | |
quoted_name = nil | |
counts = table_joins.map do |join| | |
if join.is_a?(Arel::Nodes::StringJoin) | |
# quoted_name should be case ignored as some database adapters (Oracle) return quoted name in uppercase | |
quoted_name ||= connection.quote_table_name(name) | |
# Table names + table aliases | |
join.left.scan( | |
/JOIN(?:\s+\w+)?\s+(?:\S+\s+)?(?:#{quoted_name}|#{name})\sON/i | |
).size | |
elsif join.is_a?(Arel::Nodes::Join) | |
join.left.name == name ? 1 : 0 | |
else | |
raise ArgumentError, "joins list should be initialized by list of Arel::Nodes::Join" | |
end | |
end | |
counts.sum | |
end | |
# table_joins is an array of arel joins which might conflict with the aliases we assign here | |
def initialize(connection, aliases) | |
@aliases = aliases | |
@connection = connection | |
end | |
def aliased_table_for(arel_table, table_name = nil) | |
table_name ||= arel_table.name | |
if aliases[table_name] == 0 | |
# If it's zero, we can have our table_name | |
aliases[table_name] = 1 | |
arel_table = arel_table.alias(table_name) if arel_table.name != table_name | |
else | |
# Otherwise, we need to use an alias | |
aliased_name = @connection.table_alias_for(yield) | |
# Update the count | |
count = aliases[aliased_name] += 1 | |
aliased_name = "#{truncate(aliased_name)}_#{count}" if count > 1 | |
arel_table = arel_table.alias(aliased_name) | |
end | |
arel_table | |
end | |
attr_reader :aliases | |
private | |
def truncate(name) | |
name.slice(0, @connection.table_alias_length - 2) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
class Module | |
# Allows you to make aliases for attributes, which includes | |
# getter, setter, and a predicate. | |
# | |
# class Content < ActiveRecord::Base | |
# # has a title attribute | |
# end | |
# | |
# class Email < Content | |
# alias_attribute :subject, :title | |
# end | |
# | |
# e = Email.find(1) | |
# e.title # => "Superstars" | |
# e.subject # => "Superstars" | |
# e.subject? # => true | |
# e.subject = "Megastars" | |
# e.title # => "Megastars" | |
def alias_attribute(new_name, old_name) | |
# The following reader methods use an explicit `self` receiver in order to | |
# support aliases that start with an uppercase letter. Otherwise, they would | |
# be resolved as constants instead. | |
module_eval <<-STR, __FILE__, __LINE__ + 1 | |
def #{new_name}; self.#{old_name}; end # def subject; self.title; end | |
def #{new_name}?; self.#{old_name}?; end # def subject?; self.title?; end | |
def #{new_name}=(v); self.#{old_name} = v; end # def subject=(v); self.title = v; end | |
STR | |
end | |
end |
# frozen_string_literal: true | |
require "active_support" | |
require "active_support/time" | |
require "active_support/core_ext" |
# frozen_string_literal: true | |
require "active_storage/analyzer/null_analyzer" | |
module ActiveStorage::Blob::Analyzable | |
# Extracts and stores metadata from the file associated with this blob using a relevant analyzer. Active Storage comes | |
# with built-in analyzers for images and videos. See ActiveStorage::Analyzer::ImageAnalyzer and | |
# ActiveStorage::Analyzer::VideoAnalyzer for information about the specific attributes they extract and the third-party | |
# libraries they require. | |
# | |
# To choose the analyzer for a blob, Active Storage calls +accept?+ on each registered analyzer in order. It uses the | |
# first analyzer for which +accept?+ returns true when given the blob. If no registered analyzer accepts the blob, no | |
# metadata is extracted from it. | |
# | |
# In a Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+ | |
# in an initializer: | |
# | |
# # Add a custom analyzer for Microsoft Office documents: | |
# Rails.application.config.active_storage.analyzers.append DOCXAnalyzer | |
# | |
# # Remove the built-in video analyzer: | |
# Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer | |
# | |
# Outside of a Rails application, manipulate +ActiveStorage.analyzers+ instead. | |
# | |
# You won't ordinarily need to call this method from a Rails application. New blobs are automatically and asynchronously | |
# analyzed via #analyze_later when they're attached for the first time. | |
def analyze | |
update! metadata: metadata.merge(extract_metadata_via_analyzer) | |
end | |
# Enqueues an ActiveStorage::AnalyzeJob which calls #analyze, or calls #analyze inline based on analyzer class configuration. | |
# | |
# This method is automatically called for a blob when it's attached for the first time. You can call it to analyze a blob | |
# again (e.g. if you add a new analyzer or modify an existing one). | |
def analyze_later | |
if analyzer_class.analyze_later? | |
ActiveStorage::AnalyzeJob.perform_later(self) | |
else | |
analyze | |
end | |
end | |
# Returns true if the blob has been analyzed. | |
def analyzed? | |
analyzed | |
end | |
private | |
def extract_metadata_via_analyzer | |
analyzer.metadata.merge(analyzed: true) | |
end | |
def analyzer | |
analyzer_class.new(self) | |
end | |
def analyzer_class | |
ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer | |
end | |
end |
# frozen_string_literal: true | |
# Provides asynchronous analysis of ActiveStorage::Blob records via ActiveStorage::Blob#analyze_later. | |
class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob | |
queue_as { ActiveStorage.queues[:analysis] } | |
discard_on ActiveRecord::RecordNotFound | |
retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer | |
def perform(blob) | |
blob.analyze | |
end | |
end |
# frozen_string_literal: true | |
require "test_helper" | |
require "database/setup" | |
class ActiveStorage::AnalyzeJobTest < ActiveJob::TestCase | |
setup { @blob = create_blob } | |
test "ignores missing blob" do | |
@blob.purge | |
perform_enqueued_jobs do | |
assert_nothing_raised do | |
ActiveStorage::AnalyzeJob.perform_later @blob | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveStorage | |
# This is an abstract base class for analyzers, which extract metadata from blobs. See | |
# ActiveStorage::Analyzer::VideoAnalyzer for an example of a concrete subclass. | |
class Analyzer | |
attr_reader :blob | |
# Implement this method in a concrete subclass. Have it return true when given a blob from which | |
# the analyzer can extract metadata. | |
def self.accept?(blob) | |
false | |
end | |
# Implement this method in concrete subclasses. It will determine if blob analysis | |
# should be done in a job or performed inline. By default, analysis is enqueued in a job. | |
def self.analyze_later? | |
true | |
end | |
def initialize(blob) | |
@blob = blob | |
end | |
# Override this method in a concrete subclass. Have it return a Hash of metadata. | |
def metadata | |
raise NotImplementedError | |
end | |
private | |
# Downloads the blob to a tempfile on disk. Yields the tempfile. | |
def download_blob_to_tempfile(&block) # :doc: | |
blob.open tmpdir: tmpdir, &block | |
end | |
def logger # :doc: | |
ActiveStorage.logger | |
end | |
def tmpdir # :doc: | |
Dir.tmpdir | |
end | |
def instrument(analyzer, &block) # :doc: | |
ActiveSupport::Notifications.instrument("analyze.active_storage", analyzer: analyzer, &block) | |
end | |
end | |
end |
# frozen_string_literal: true | |
module Arel # :nodoc: all | |
module Nodes | |
class And < Arel::Nodes::NodeExpression | |
attr_reader :children | |
def initialize(children) | |
super() | |
@children = children | |
end | |
def left | |
children.first | |
end | |
def right | |
children[1] | |
end | |
def hash | |
children.hash | |
end | |
def eql?(other) | |
self.class == other.class && | |
self.children == other.children | |
end | |
alias :== :eql? | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/author" | |
module ActiveRecord | |
class AndTest < ActiveRecord::TestCase | |
fixtures :authors, :author_addresses | |
def test_and | |
david, mary, bob = authors(:david, :mary, :bob) | |
david_and_mary = Author.where(id: [david, mary]).order(:id) | |
mary_and_bob = Author.where(id: [mary, bob]).order(:id) | |
assert_equal [mary], david_and_mary.and(mary_and_bob) | |
end | |
def test_and_with_non_relation_attribute | |
hash = { "id" => 123 } | |
error = assert_raises(ArgumentError) do | |
Author.and(hash) | |
end | |
assert_equal( | |
"You have passed Hash object to #and. Pass an ActiveRecord::Relation object instead.", | |
error.message | |
) | |
end | |
def test_and_with_structurally_incompatible_scope | |
posts_scope = Author.unscope(:order).limit(10).offset(10).select(:id).order(:id) | |
error = assert_raises(ArgumentError) do | |
Author.limit(10).select(:id).order(:name).and(posts_scope) | |
end | |
assert_equal( | |
"Relation passed to #and must be structurally compatible. Incompatible values: [:order, :offset]", | |
error.message | |
) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/post" | |
class AnnotateTest < ActiveRecord::TestCase | |
fixtures :posts | |
def test_annotate_wraps_content_in_an_inline_comment | |
quoted_posts_id, quoted_posts = regexp_escape_table_name("posts.id"), regexp_escape_table_name("posts") | |
assert_sql(%r{SELECT #{quoted_posts_id} FROM #{quoted_posts} /\* foo \*/}i) do | |
posts = Post.select(:id).annotate("foo") | |
assert posts.first | |
end | |
end | |
def test_annotate_is_sanitized | |
quoted_posts_id, quoted_posts = regexp_escape_table_name("posts.id"), regexp_escape_table_name("posts") | |
assert_sql(%r{SELECT #{quoted_posts_id} FROM #{quoted_posts} /\* foo \*/}i) do | |
posts = Post.select(:id).annotate("*/foo/*") | |
assert posts.first | |
end | |
assert_sql(%r{SELECT #{quoted_posts_id} FROM #{quoted_posts} /\* foo \*/}i) do | |
posts = Post.select(:id).annotate("**//foo//**") | |
assert posts.first | |
end | |
assert_sql(%r{SELECT #{quoted_posts_id} FROM #{quoted_posts} /\* foo \*/ /\* bar \*/}i) do | |
posts = Post.select(:id).annotate("*/foo/*").annotate("*/bar") | |
assert posts.first | |
end | |
assert_sql(%r{SELECT #{quoted_posts_id} FROM #{quoted_posts} /\* \+ MAX_EXECUTION_TIME\(1\) \*/}i) do | |
posts = Post.select(:id).annotate("+ MAX_EXECUTION_TIME(1)") | |
assert posts.first | |
end | |
end | |
private | |
def regexp_escape_table_name(name) | |
Regexp.escape(Post.connection.quote_table_name(name)) | |
end | |
end |
# frozen_string_literal: true | |
class Module | |
# A module may or may not have a name. | |
# | |
# module M; end | |
# M.name # => "M" | |
# | |
# m = Module.new | |
# m.name # => nil | |
# | |
# +anonymous?+ method returns true if module does not have a name, false otherwise: | |
# | |
# Module.new.anonymous? # => true | |
# | |
# module M; end | |
# M.anonymous? # => false | |
# | |
# A module gets a name when it is first assigned to a constant. Either | |
# via the +module+ or +class+ keyword or by an explicit assignment: | |
# | |
# m = Module.new # creates an anonymous module | |
# m.anonymous? # => true | |
# M = m # m gets a name here as a side-effect | |
# m.name # => "M" | |
# m.anonymous? # => false | |
def anonymous? | |
name.nil? | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/module/anonymous" | |
class AnonymousTest < ActiveSupport::TestCase | |
test "an anonymous class or module are anonymous" do | |
assert_predicate Module.new, :anonymous? | |
assert_predicate Class.new, :anonymous? | |
end | |
test "a named class or module are not anonymous" do | |
assert_not_predicate Kernel, :anonymous? | |
assert_not_predicate Object, :anonymous? | |
end | |
end |
# frozen_string_literal: true | |
class Fixtures::AnotherClass | |
end |
# frozen_string_literal: true | |
module ActiveModel | |
# == Active \Model \API | |
# | |
# Includes the required interface for an object to interact with | |
# Action Pack and Action View, using different Active Model modules. | |
# It includes model name introspections, conversions, translations, and | |
# validations. Besides that, it allows you to initialize the object with a | |
# hash of attributes, pretty much like Active Record does. | |
# | |
# A minimal implementation could be: | |
# | |
# class Person | |
# include ActiveModel::API | |
# attr_accessor :name, :age | |
# end | |
# | |
# person = Person.new(name: 'bob', age: '18') | |
# person.name # => "bob" | |
# person.age # => "18" | |
# | |
# Note that, by default, <tt>ActiveModel::API</tt> implements <tt>persisted?</tt> | |
# to return +false+, which is the most common case. You may want to override | |
# it in your class to simulate a different scenario: | |
# | |
# class Person | |
# include ActiveModel::API | |
# attr_accessor :id, :name | |
# | |
# def persisted? | |
# self.id.present? | |
# end | |
# end | |
# | |
# person = Person.new(id: 1, name: 'bob') | |
# person.persisted? # => true | |
# | |
# Also, if for some reason you need to run code on <tt>initialize</tt>, make | |
# sure you call +super+ if you want the attributes hash initialization to | |
# happen. | |
# | |
# class Person | |
# include ActiveModel::API | |
# attr_accessor :id, :name, :omg | |
# | |
# def initialize(attributes={}) | |
# super | |
# @omg ||= true | |
# end | |
# end | |
# | |
# person = Person.new(id: 1, name: 'bob') | |
# person.omg # => true | |
# | |
# For more detailed information on other functionalities available, please | |
# refer to the specific modules included in <tt>ActiveModel::API</tt> | |
# (see below). | |
module API | |
extend ActiveSupport::Concern | |
include ActiveModel::AttributeAssignment | |
include ActiveModel::Validations | |
include ActiveModel::Conversion | |
included do | |
extend ActiveModel::Naming | |
extend ActiveModel::Translation | |
end | |
# Initializes a new model with the given +params+. | |
# | |
# class Person | |
# include ActiveModel::API | |
# attr_accessor :name, :age | |
# end | |
# | |
# person = Person.new(name: 'bob', age: '18') | |
# person.name # => "bob" | |
# person.age # => "18" | |
def initialize(attributes = {}) | |
assign_attributes(attributes) if attributes | |
super() | |
end | |
# Indicates if the model is persisted. Default is +false+. | |
# | |
# class Person | |
# include ActiveModel::API | |
# attr_accessor :id, :name | |
# end | |
# | |
# person = Person.new(id: 1, name: 'bob') | |
# person.persisted? # => false | |
def persisted? | |
false | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
class APITest < ActiveModel::TestCase | |
include ActiveModel::Lint::Tests | |
module DefaultValue | |
def self.included(klass) | |
klass.class_eval { attr_accessor :hello } | |
end | |
def initialize(*args) | |
@attr ||= "default value" | |
super | |
end | |
end | |
class BasicModel | |
include DefaultValue | |
include ActiveModel::API | |
attr_accessor :attr | |
end | |
class BasicModelWithReversedMixins | |
include ActiveModel::API | |
include DefaultValue | |
attr_accessor :attr | |
end | |
class SimpleModel | |
include ActiveModel::API | |
attr_accessor :attr | |
end | |
def setup | |
@model = BasicModel.new | |
end | |
def test_initialize_with_params | |
object = BasicModel.new(attr: "value") | |
assert_equal "value", object.attr | |
end | |
def test_initialize_with_params_and_mixins_reversed | |
object = BasicModelWithReversedMixins.new(attr: "value") | |
assert_equal "value", object.attr | |
end | |
def test_initialize_with_nil_or_empty_hash_params_does_not_explode | |
assert_nothing_raised do | |
BasicModel.new() | |
BasicModel.new(nil) | |
BasicModel.new({}) | |
SimpleModel.new(attr: "value") | |
end | |
end | |
def test_persisted_is_always_false | |
object = BasicModel.new(attr: "value") | |
assert_not object.persisted? | |
end | |
def test_mixin_inclusion_chain | |
object = BasicModel.new | |
assert_equal "default value", object.attr | |
end | |
def test_mixin_initializer_when_args_exist | |
object = BasicModel.new(hello: "world") | |
assert_equal "world", object.hello | |
end | |
def test_mixin_initializer_when_args_dont_exist | |
assert_raises(ActiveModel::UnknownAttributeError) do | |
SimpleModel.new(hello: "world") | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "boot" | |
require "rails" | |
require "active_model/railtie" | |
require "active_job/railtie" | |
require "active_record/railtie" | |
require "action_controller/railtie" | |
require "action_view/railtie" | |
require "sprockets/railtie" | |
require "active_storage/engine" | |
Bundler.require(*Rails.groups) | |
module Dummy | |
class Application < Rails::Application | |
config.load_defaults Rails::VERSION::STRING.to_f | |
config.active_storage.service = :local | |
end | |
end |
# frozen_string_literal: true | |
class ApplicationController < ActionController::Base | |
protect_from_forgery with: :exception | |
end |
# frozen_string_literal: true | |
module ApplicationHelper | |
end |
# frozen_string_literal: true | |
class ApplicationJob < ActiveJob::Base | |
end |
# frozen_string_literal: true | |
class ApplicationRecord < ActiveRecord::Base | |
self.abstract_class = true | |
end |
# frozen_string_literal: true | |
require "rails/generators/active_record" | |
module ActiveRecord | |
module Generators # :nodoc: | |
class ApplicationRecordGenerator < ::Rails::Generators::Base # :nodoc: | |
source_root File.expand_path("templates", __dir__) | |
# FIXME: Change this file to a symlink once RubyGems 2.5.0 is required. | |
def create_application_record | |
template "application_record.rb", application_record_file_name | |
end | |
private | |
def application_record_file_name | |
@application_record_file_name ||= | |
if namespaced? | |
"app/models/#{namespaced_path}/application_record.rb" | |
else | |
"app/models/application_record.rb" | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "arel/errors" | |
require "arel/crud" | |
require "arel/factory_methods" | |
require "arel/expressions" | |
require "arel/predications" | |
require "arel/filter_predications" | |
require "arel/window_predications" | |
require "arel/math" | |
require "arel/alias_predication" | |
require "arel/order_predications" | |
require "arel/table" | |
require "arel/attributes/attribute" | |
require "arel/visitors" | |
require "arel/collectors/sql_string" | |
require "arel/tree_manager" | |
require "arel/insert_manager" | |
require "arel/select_manager" | |
require "arel/update_manager" | |
require "arel/delete_manager" | |
require "arel/nodes" | |
module Arel | |
VERSION = "10.0.0" | |
# Wrap a known-safe SQL string for passing to query methods, e.g. | |
# | |
# Post.order(Arel.sql("REPLACE(title, 'misc', 'zzzz') asc")).pluck(:id) | |
# | |
# Great caution should be taken to avoid SQL injection vulnerabilities. | |
# This method should not be used with unsafe values such as request | |
# parameters or model attributes. | |
def self.sql(raw_sql) | |
Arel::Nodes::SqlLiteral.new raw_sql | |
end | |
def self.star # :nodoc: | |
sql "*" | |
end | |
def self.arel_node?(value) # :nodoc: | |
value.is_a?(Arel::Nodes::Node) || value.is_a?(Arel::Attribute) || value.is_a?(Arel::Nodes::SqlLiteral) | |
end | |
def self.fetch_attribute(value, &block) # :nodoc: | |
unless String === value | |
value.fetch_attribute(&block) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "helper" | |
require "active_job/arguments" | |
require "models/person" | |
require "active_support/core_ext/hash/indifferent_access" | |
require "jobs/kwargs_job" | |
require "support/stubs/strong_parameters" | |
class ArgumentSerializationTest < ActiveSupport::TestCase | |
module ModuleArgument | |
class ClassArgument; end | |
end | |
class ClassArgument; end | |
setup do | |
@person = Person.find("5") | |
end | |
[ nil, 1, 1.0, 1_000_000_000_000_000_000_000, | |
"a", true, false, BigDecimal(5), | |
:a, | |
1.day, | |
Date.new(2001, 2, 3), | |
Time.new(2002, 10, 31, 2, 2, 2.123456789r, "+02:00"), | |
DateTime.new(2001, 2, 3, 4, 5, 6.123456r, "+03:00"), | |
ActiveSupport::TimeWithZone.new(Time.utc(1999, 12, 31, 23, 59, "59.123456789".to_r), ActiveSupport::TimeZone["UTC"]), | |
[ 1, "a" ], | |
{ "a" => 1 }, | |
ModuleArgument, | |
ModuleArgument::ClassArgument, | |
ClassArgument, | |
1.., | |
1..., | |
1..5, | |
1...5, | |
"a".."z", | |
"A".."Z", | |
Date.new(2001, 2, 3).., | |
10.days.ago..Date.today, | |
Time.new(2002, 10, 31, 2, 2, 2.123456789r, "+02:00").., | |
10.hours.ago..Time.current, | |
DateTime.new(2001, 2, 3, 4, 5, 6.123456r, "+03:00").., | |
(DateTime.current - 4.weeks)..DateTime.current, | |
ActiveSupport::TimeWithZone.new(Time.utc(1999, 12, 31, 23, 59, "59.123456789".to_r), ActiveSupport::TimeZone["UTC"]).., | |
].each do |arg| | |
test "serializes #{arg.class} - #{arg.inspect} verbatim" do | |
assert_arguments_unchanged arg | |
end | |
end | |
[ Object.new, Person.find("5").to_gid, Class.new ].each do |arg| | |
test "does not serialize #{arg.class}" do | |
assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [ arg ] | |
end | |
assert_raises ActiveJob::DeserializationError do | |
ActiveJob::Arguments.deserialize [ arg ] | |
end | |
end | |
end | |
test "should convert records to Global IDs" do | |
assert_arguments_roundtrip [@person] | |
end | |
test "should keep Global IDs strings as they are" do | |
assert_arguments_roundtrip [@person.to_gid.to_s] | |
end | |
test "should dive deep into arrays and hashes" do | |
assert_arguments_roundtrip [3, [@person]] | |
assert_arguments_roundtrip [{ "a" => @person }] | |
end | |
test "should maintain string and symbol keys" do | |
assert_arguments_roundtrip([a: 1, "b" => 2]) | |
end | |
test "serialize a ActionController::Parameters" do | |
parameters = Parameters.new(a: 1) | |
assert_equal( | |
{ "a" => 1, "_aj_hash_with_indifferent_access" => true }, | |
ActiveJob::Arguments.serialize([parameters]).first | |
) | |
end | |
test "serialize a hash" do | |
symbol_key = { a: 1 } | |
string_key = { "a" => 1 } | |
indifferent_access = { a: 1 }.with_indifferent_access | |
assert_equal( | |
{ "a" => 1, "_aj_symbol_keys" => ["a"] }, | |
ActiveJob::Arguments.serialize([symbol_key]).first | |
) | |
assert_equal( | |
{ "a" => 1, "_aj_symbol_keys" => [] }, | |
ActiveJob::Arguments.serialize([string_key]).first | |
) | |
assert_equal( | |
{ "a" => 1, "_aj_hash_with_indifferent_access" => true }, | |
ActiveJob::Arguments.serialize([indifferent_access]).first | |
) | |
end | |
test "deserialize a hash" do | |
symbol_key = { "a" => 1, "_aj_symbol_keys" => ["a"] } | |
string_key = { "a" => 1, "_aj_symbol_keys" => [] } | |
another_string_key = { "a" => 1 } | |
indifferent_access = { "a" => 1, "_aj_hash_with_indifferent_access" => true } | |
indifferent_access_symbol_key = symbol_key.with_indifferent_access | |
assert_equal( | |
{ a: 1 }, | |
ActiveJob::Arguments.deserialize([symbol_key]).first | |
) | |
assert_equal( | |
{ "a" => 1 }, | |
ActiveJob::Arguments.deserialize([string_key]).first | |
) | |
assert_equal( | |
{ "a" => 1 }, | |
ActiveJob::Arguments.deserialize([another_string_key]).first | |
) | |
assert_equal( | |
{ "a" => 1 }, | |
ActiveJob::Arguments.deserialize([indifferent_access]).first | |
) | |
assert_equal( | |
{ a: 1 }, | |
ActiveJob::Arguments.deserialize([indifferent_access_symbol_key]).first | |
) | |
end | |
test "should maintain hash with indifferent access" do | |
symbol_key = { a: 1 } | |
string_key = { "a" => 1 } | |
indifferent_access = { a: 1 }.with_indifferent_access | |
assert_not_instance_of ActiveSupport::HashWithIndifferentAccess, perform_round_trip([symbol_key]).first | |
assert_not_instance_of ActiveSupport::HashWithIndifferentAccess, perform_round_trip([string_key]).first | |
assert_instance_of ActiveSupport::HashWithIndifferentAccess, perform_round_trip([indifferent_access]).first | |
end | |
test "should maintain time with zone" do | |
Time.use_zone "Alaska" do | |
time_with_zone = Time.new(2002, 10, 31, 2, 2, 2).in_time_zone | |
assert_instance_of ActiveSupport::TimeWithZone, perform_round_trip([time_with_zone]).first | |
assert_arguments_unchanged time_with_zone | |
end | |
end | |
test "should disallow non-string/symbol hash keys" do | |
assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [ { 1 => 2 } ] | |
end | |
assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [ { a: [{ 2 => 3 }] } ] | |
end | |
end | |
test "should not allow reserved hash keys" do | |
["_aj_globalid", :_aj_globalid, | |
"_aj_symbol_keys", :_aj_symbol_keys, | |
"_aj_hash_with_indifferent_access", :_aj_hash_with_indifferent_access, | |
"_aj_serialized", :_aj_serialized].each do |key| | |
assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [key => 1] | |
end | |
end | |
end | |
test "should not allow non-primitive objects" do | |
assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [Object.new] | |
end | |
assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [1, [Object.new]] | |
end | |
end | |
test "allows for keyword arguments" do | |
KwargsJob.perform_now(argument: 2) | |
assert_equal "Job with argument: 2", JobBuffer.last_value | |
end | |
test "raises a friendly SerializationError for records without ids" do | |
err = assert_raises ActiveJob::SerializationError do | |
ActiveJob::Arguments.serialize [Person.new(nil)] | |
end | |
assert_match "Unable to serialize Person without an id.", err.message | |
end | |
private | |
def assert_arguments_unchanged(*args) | |
assert_arguments_roundtrip args | |
end | |
def assert_arguments_roundtrip(args) | |
assert_equal args, perform_round_trip(args) | |
end | |
def perform_round_trip(args) | |
ActiveJob::Arguments.deserialize(ActiveJob::Arguments.serialize(args)) | |
end | |
end |
# frozen_string_literal: true | |
require "bigdecimal" | |
require "active_support/core_ext/hash" | |
module ActiveJob | |
# Raised when an exception is raised during job arguments deserialization. | |
# | |
# Wraps the original exception raised as +cause+. | |
class DeserializationError < StandardError | |
def initialize # :nodoc: | |
super("Error while trying to deserialize arguments: #{$!.message}") | |
set_backtrace $!.backtrace | |
end | |
end | |
# Raised when an unsupported argument type is set as a job argument. We | |
# currently support String, Integer, Float, NilClass, TrueClass, FalseClass, | |
# BigDecimal, Symbol, Date, Time, DateTime, ActiveSupport::TimeWithZone, | |
# ActiveSupport::Duration, Hash, ActiveSupport::HashWithIndifferentAccess, | |
# Array, Range, or GlobalID::Identification instances, although this can be | |
# extended by adding custom serializers. | |
# Raised if you set the key for a Hash something else than a string or | |
# a symbol. Also raised when trying to serialize an object which can't be | |
# identified with a GlobalID - such as an unpersisted Active Record model. | |
class SerializationError < ArgumentError; end | |
module Arguments | |
extend self | |
# Serializes a set of arguments. Intrinsic types that can safely be | |
# serialized without mutation are returned as-is. Arrays/Hashes are | |
# serialized element by element. All other types are serialized using | |
# GlobalID. | |
def serialize(arguments) | |
arguments.map { |argument| serialize_argument(argument) } | |
end | |
# Deserializes a set of arguments. Intrinsic types that can safely be | |
# deserialized without mutation are returned as-is. Arrays/Hashes are | |
# deserialized element by element. All other types are deserialized using | |
# GlobalID. | |
def deserialize(arguments) | |
arguments.map { |argument| deserialize_argument(argument) } | |
rescue | |
raise DeserializationError | |
end | |
private | |
# :nodoc: | |
PERMITTED_TYPES = [ NilClass, String, Integer, Float, BigDecimal, TrueClass, FalseClass ] | |
# :nodoc: | |
GLOBALID_KEY = "_aj_globalid" | |
# :nodoc: | |
SYMBOL_KEYS_KEY = "_aj_symbol_keys" | |
# :nodoc: | |
RUBY2_KEYWORDS_KEY = "_aj_ruby2_keywords" | |
# :nodoc: | |
WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access" | |
# :nodoc: | |
OBJECT_SERIALIZER_KEY = "_aj_serialized" | |
# :nodoc: | |
RESERVED_KEYS = [ | |
GLOBALID_KEY, GLOBALID_KEY.to_sym, | |
SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym, | |
RUBY2_KEYWORDS_KEY, RUBY2_KEYWORDS_KEY.to_sym, | |
OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym, | |
WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym, | |
] | |
private_constant :PERMITTED_TYPES, :RESERVED_KEYS, :GLOBALID_KEY, | |
:SYMBOL_KEYS_KEY, :RUBY2_KEYWORDS_KEY, :WITH_INDIFFERENT_ACCESS_KEY | |
unless Hash.respond_to?(:ruby2_keywords_hash?) && Hash.respond_to?(:ruby2_keywords_hash) | |
using Module.new { | |
refine Hash do | |
class << Hash | |
def ruby2_keywords_hash?(hash) | |
!new(*[hash]).default.equal?(hash) | |
end | |
def ruby2_keywords_hash(hash) | |
_ruby2_keywords_hash(**hash) | |
end | |
private | |
def _ruby2_keywords_hash(*args) | |
args.last | |
end | |
ruby2_keywords(:_ruby2_keywords_hash) | |
end | |
end | |
} | |
end | |
def serialize_argument(argument) | |
case argument | |
when *PERMITTED_TYPES | |
argument | |
when GlobalID::Identification | |
convert_to_global_id_hash(argument) | |
when Array | |
argument.map { |arg| serialize_argument(arg) } | |
when ActiveSupport::HashWithIndifferentAccess | |
serialize_indifferent_hash(argument) | |
when Hash | |
symbol_keys = argument.each_key.grep(Symbol).map!(&:to_s) | |
aj_hash_key = if Hash.ruby2_keywords_hash?(argument) | |
RUBY2_KEYWORDS_KEY | |
else | |
SYMBOL_KEYS_KEY | |
end | |
result = serialize_hash(argument) | |
result[aj_hash_key] = symbol_keys | |
result | |
when -> (arg) { arg.respond_to?(:permitted?) } | |
serialize_indifferent_hash(argument.to_h) | |
else | |
Serializers.serialize(argument) | |
end | |
end | |
def deserialize_argument(argument) | |
case argument | |
when String | |
argument | |
when *PERMITTED_TYPES | |
argument | |
when Array | |
argument.map { |arg| deserialize_argument(arg) } | |
when Hash | |
if serialized_global_id?(argument) | |
deserialize_global_id argument | |
elsif custom_serialized?(argument) | |
Serializers.deserialize(argument) | |
else | |
deserialize_hash(argument) | |
end | |
else | |
raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}" | |
end | |
end | |
def serialized_global_id?(hash) | |
hash.size == 1 && hash.include?(GLOBALID_KEY) | |
end | |
def deserialize_global_id(hash) | |
GlobalID::Locator.locate hash[GLOBALID_KEY] | |
end | |
def custom_serialized?(hash) | |
hash.key?(OBJECT_SERIALIZER_KEY) | |
end | |
def serialize_hash(argument) | |
argument.each_with_object({}) do |(key, value), hash| | |
hash[serialize_hash_key(key)] = serialize_argument(value) | |
end | |
end | |
def deserialize_hash(serialized_hash) | |
result = serialized_hash.transform_values { |v| deserialize_argument(v) } | |
if result.delete(WITH_INDIFFERENT_ACCESS_KEY) | |
result = result.with_indifferent_access | |
elsif symbol_keys = result.delete(SYMBOL_KEYS_KEY) | |
result = transform_symbol_keys(result, symbol_keys) | |
elsif symbol_keys = result.delete(RUBY2_KEYWORDS_KEY) | |
result = transform_symbol_keys(result, symbol_keys) | |
result = Hash.ruby2_keywords_hash(result) | |
end | |
result | |
end | |
def serialize_hash_key(key) | |
case key | |
when *RESERVED_KEYS | |
raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") | |
when String, Symbol | |
key.to_s | |
else | |
raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") | |
end | |
end | |
def serialize_indifferent_hash(indifferent_hash) | |
result = serialize_hash(indifferent_hash) | |
result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) | |
result | |
end | |
def transform_symbol_keys(hash, symbol_keys) | |
# NOTE: HashWithIndifferentAccess#transform_keys always | |
# returns stringified keys with indifferent access | |
# so we call #to_h here to ensure keys are symbolized. | |
hash.to_h.transform_keys do |key| | |
if symbol_keys.include?(key) | |
key.to_sym | |
else | |
key | |
end | |
end | |
end | |
def convert_to_global_id_hash(argument) | |
{ GLOBALID_KEY => argument.to_global_id.to_s } | |
rescue URI::GID::MissingModelIdError | |
raise SerializationError, "Unable to serialize #{argument.class} " \ | |
"without an id. (Maybe you forgot to call save?)" | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/array/wrap" | |
require "active_support/core_ext/array/access" | |
require "active_support/core_ext/array/conversions" | |
require "active_support/core_ext/array/deprecated_conversions" unless ENV["RAILS_DISABLE_DEPRECATED_TO_S_CONVERSION"] | |
require "active_support/core_ext/array/extract" | |
require "active_support/core_ext/array/extract_options" | |
require "active_support/core_ext/array/grouping" | |
require "active_support/core_ext/array/inquiry" |
# frozen_string_literal: true | |
require "active_support/core_ext/array/extract" | |
module ActiveRecord | |
class PredicateBuilder | |
class ArrayHandler # :nodoc: | |
def initialize(predicate_builder) | |
@predicate_builder = predicate_builder | |
end | |
def call(attribute, value) | |
return attribute.in([]) if value.empty? | |
values = value.map { |x| x.is_a?(Base) ? x.id : x } | |
nils = values.extract!(&:nil?) | |
ranges = values.extract! { |v| v.is_a?(Range) } | |
values_predicate = | |
case values.length | |
when 0 then NullPredicate | |
when 1 then predicate_builder.build(attribute, values.first) | |
else Arel::Nodes::HomogeneousIn.new(values, attribute, :in) | |
end | |
unless nils.empty? | |
values_predicate = values_predicate.or(attribute.eq(nil)) | |
end | |
if ranges.empty? | |
values_predicate | |
else | |
array_predicates = ranges.map! { |range| predicate_builder.build(attribute, range) } | |
array_predicates.inject(values_predicate, &:or) | |
end | |
end | |
private | |
attr_reader :predicate_builder | |
module NullPredicate # :nodoc: | |
def self.or(other) | |
other | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveSupport | |
# Wrapping an array in an +ArrayInquirer+ gives a friendlier way to check | |
# its string-like contents: | |
# | |
# variants = ActiveSupport::ArrayInquirer.new([:phone, :tablet]) | |
# | |
# variants.phone? # => true | |
# variants.tablet? # => true | |
# variants.desktop? # => false | |
class ArrayInquirer < Array | |
# Passes each element of +candidates+ collection to ArrayInquirer collection. | |
# The method returns true if any element from the ArrayInquirer collection | |
# is equal to the stringified or symbolized form of any element in the +candidates+ collection. | |
# | |
# If +candidates+ collection is not given, method returns true. | |
# | |
# variants = ActiveSupport::ArrayInquirer.new([:phone, :tablet]) | |
# | |
# variants.any? # => true | |
# variants.any?(:phone, :tablet) # => true | |
# variants.any?('phone', 'desktop') # => true | |
# variants.any?(:desktop, :watch) # => false | |
def any?(*candidates) | |
if candidates.none? | |
super | |
else | |
candidates.any? do |candidate| | |
include?(candidate.to_sym) || include?(candidate.to_s) | |
end | |
end | |
end | |
private | |
def respond_to_missing?(name, include_private = false) | |
name.end_with?("?") || super | |
end | |
def method_missing(name, *args) | |
if name.end_with?("?") | |
any?(name[0..-2]) | |
else | |
super | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "abstract_unit" | |
require "active_support/core_ext/array" | |
class ArrayInquirerTest < ActiveSupport::TestCase | |
def setup | |
@array_inquirer = ActiveSupport::ArrayInquirer.new([:mobile, :tablet, "api"]) | |
end | |
def test_individual | |
assert_predicate @array_inquirer, :mobile? | |
assert_predicate @array_inquirer, :tablet? | |
assert_not_predicate @array_inquirer, :desktop? | |
end | |
def test_any | |
assert @array_inquirer.any?(:mobile, :desktop) | |
assert @array_inquirer.any?(:watch, :tablet) | |
assert_not @array_inquirer.any?(:desktop, :watch) | |
end | |
def test_any_string_symbol_mismatch | |
assert @array_inquirer.any?("mobile") | |
assert @array_inquirer.any?(:api) | |
end | |
def test_any_with_block | |
assert @array_inquirer.any? { |v| v == :mobile } | |
assert_not @array_inquirer.any? { |v| v == :desktop } | |
end | |
def test_respond_to | |
assert_respond_to @array_inquirer, :development? | |
end | |
def test_inquiry | |
result = [:mobile, :tablet, "api"].inquiry | |
assert_instance_of ActiveSupport::ArrayInquirer, result | |
assert_equal @array_inquirer, result | |
end | |
def test_respond_to_fallback_to_array_respond_to | |
Array.class_eval do | |
def respond_to_missing?(name, include_private = false) | |
(name == :foo) || super | |
end | |
end | |
arr = ActiveSupport::ArrayInquirer.new([:x]) | |
assert_respond_to arr, :can_you_hear_me? | |
assert_respond_to arr, :foo | |
assert_not_respond_to arr, :nope | |
ensure | |
Array.class_eval do | |
undef_method :respond_to_missing? | |
def respond_to_missing?(name, include_private = false) # rubocop:disable Lint/DuplicateMethods | |
super | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "support/schema_dumping_helper" | |
class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase | |
include SchemaDumpingHelper | |
include InTimeZone | |
class PgArray < ActiveRecord::Base | |
self.table_name = "pg_arrays" | |
end | |
def setup | |
@connection = ActiveRecord::Base.connection | |
enable_extension!("hstore", @connection) | |
@connection.transaction do | |
@connection.create_table "pg_arrays", force: true do |t| | |
t.string "tags", array: true, limit: 255 | |
t.integer "ratings", array: true | |
t.datetime :datetimes, array: true | |
t.hstore :hstores, array: true | |
t.decimal :decimals, array: true, default: [], precision: 10, scale: 2 | |
t.timestamp :timestamps, array: true, default: [], precision: 6 | |
end | |
end | |
PgArray.reset_column_information | |
@column = PgArray.columns_hash["tags"] | |
@type = PgArray.type_for_attribute("tags") | |
end | |
teardown do | |
@connection.drop_table "pg_arrays", if_exists: true | |
disable_extension!("hstore", @connection) | |
end | |
def test_column | |
assert_equal :string, @column.type | |
assert_equal "character varying(255)", @column.sql_type | |
assert_predicate @column, :array? | |
assert_not_predicate @type, :binary? | |
ratings_column = PgArray.columns_hash["ratings"] | |
assert_equal :integer, ratings_column.type | |
assert_predicate ratings_column, :array? | |
end | |
def test_not_compatible_with_serialize_array | |
new_klass = Class.new(PgArray) do | |
serialize :tags, Array | |
end | |
assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do | |
new_klass.new | |
end | |
end | |
class MyTags | |
def initialize(tags); @tags = tags end | |
def to_a; @tags end | |
def self.load(tags); new(tags) end | |
def self.dump(object); object.to_a end | |
end | |
def test_array_with_serialized_attributes | |
new_klass = Class.new(PgArray) do | |
serialize :tags, MyTags | |
end | |
new_klass.create!(tags: MyTags.new(["one", "two"])) | |
record = new_klass.first | |
assert_instance_of MyTags, record.tags | |
assert_equal ["one", "two"], record.tags.to_a | |
record.tags = MyTags.new(["three", "four"]) | |
record.save! | |
assert_equal ["three", "four"], record.reload.tags.to_a | |
end | |
def test_default | |
@connection.add_column "pg_arrays", "score", :integer, array: true, default: [4, 4, 2] | |
PgArray.reset_column_information | |
assert_equal([4, 4, 2], PgArray.column_defaults["score"]) | |
assert_equal([4, 4, 2], PgArray.new.score) | |
ensure | |
PgArray.reset_column_information | |
end | |
def test_default_strings | |
@connection.add_column "pg_arrays", "names", :string, array: true, default: ["foo", "bar"] | |
PgArray.reset_column_information | |
assert_equal(["foo", "bar"], PgArray.column_defaults["names"]) | |
assert_equal(["foo", "bar"], PgArray.new.names) | |
ensure | |
PgArray.reset_column_information | |
end | |
def test_change_column_with_array | |
@connection.add_column :pg_arrays, :snippets, :string, array: true, default: [] | |
@connection.change_column :pg_arrays, :snippets, :text, array: true, default: [] | |
PgArray.reset_column_information | |
column = PgArray.columns_hash["snippets"] | |
assert_equal :text, column.type | |
assert_equal [], PgArray.column_defaults["snippets"] | |
assert_predicate column, :array? | |
end | |
def test_change_column_from_non_array_to_array | |
@connection.add_column :pg_arrays, :snippets, :string | |
@connection.change_column :pg_arrays, :snippets, :text, array: true, default: [], using: "string_to_array(\"snippets\", ',')" | |
PgArray.reset_column_information | |
column = PgArray.columns_hash["snippets"] | |
assert_equal :text, column.type | |
assert_equal [], PgArray.column_defaults["snippets"] | |
assert_predicate column, :array? | |
end | |
def test_change_column_cant_make_non_array_column_to_array | |
@connection.add_column :pg_arrays, :a_string, :string | |
assert_raises ActiveRecord::StatementInvalid do | |
@connection.transaction do | |
@connection.change_column :pg_arrays, :a_string, :string, array: true | |
end | |
end | |
end | |
def test_change_column_default_with_array | |
@connection.change_column_default :pg_arrays, :tags, [] | |
PgArray.reset_column_information | |
assert_equal [], PgArray.column_defaults["tags"] | |
end | |
def test_type_cast_array | |
assert_equal(["1", "2", "3"], @type.deserialize("{1,2,3}")) | |
assert_equal([], @type.deserialize("{}")) | |
assert_equal([nil], @type.deserialize("{NULL}")) | |
end | |
def test_type_cast_integers | |
x = PgArray.new(ratings: ["1", "2"]) | |
assert_equal([1, 2], x.ratings) | |
x.save! | |
x.reload | |
assert_equal([1, 2], x.ratings) | |
end | |
def test_schema_dump_with_shorthand | |
output = dump_table_schema "pg_arrays" | |
assert_match %r[t\.string\s+"tags",\s+limit: 255,\s+array: true], output | |
assert_match %r[t\.integer\s+"ratings",\s+array: true], output | |
assert_match %r[t\.decimal\s+"decimals",\s+precision: 10,\s+scale: 2,\s+default: \[\],\s+array: true], output | |
end | |
def test_select_with_strings | |
@connection.execute "insert into pg_arrays (tags) VALUES ('{1,2,3}')" | |
x = PgArray.first | |
assert_equal(["1", "2", "3"], x.tags) | |
end | |
def test_rewrite_with_strings | |
@connection.execute "insert into pg_arrays (tags) VALUES ('{1,2,3}')" | |
x = PgArray.first | |
x.tags = ["1", "2", "3", "4"] | |
x.save! | |
assert_equal ["1", "2", "3", "4"], x.reload.tags | |
end | |
def test_select_with_integers | |
@connection.execute "insert into pg_arrays (ratings) VALUES ('{1,2,3}')" | |
x = PgArray.first | |
assert_equal([1, 2, 3], x.ratings) | |
end | |
def test_rewrite_with_integers | |
@connection.execute "insert into pg_arrays (ratings) VALUES ('{1,2,3}')" | |
x = PgArray.first | |
x.ratings = [2, "3", 4] | |
x.save! | |
assert_equal [2, 3, 4], x.reload.ratings | |
end | |
def test_multi_dimensional_with_strings | |
assert_cycle(:tags, [[["1"], ["2"]], [["2"], ["3"]]]) | |
end | |
def test_with_empty_strings | |
assert_cycle(:tags, [ "1", "2", "", "4", "", "5" ]) | |
end | |
def test_with_multi_dimensional_empty_strings | |
assert_cycle(:tags, [[["1", "2"], ["", "4"], ["", "5"]]]) | |
end | |
def test_with_arbitrary_whitespace | |
assert_cycle(:tags, [[["1", "2"], [" ", "4"], [" ", "5"]]]) | |
end | |
def test_multi_dimensional_with_integers | |
assert_cycle(:ratings, [[[1], [7]], [[8], [10]]]) | |
end | |
def test_strings_with_quotes | |
assert_cycle(:tags, ["this has", 'some "s that need to be escaped"']) | |
end | |
def test_strings_with_commas | |
assert_cycle(:tags, ["this,has", "many,values"]) | |
end | |
def test_strings_with_array_delimiters | |
assert_cycle(:tags, ["{", "}"]) | |
end | |
def test_strings_with_null_strings | |
assert_cycle(:tags, ["NULL", "NULL"]) | |
end | |
def test_contains_nils | |
assert_cycle(:tags, ["1", nil, nil]) | |
end | |
def test_insert_fixture | |
tag_values = ["val1", "val2", "val3_with_'_multiple_quote_'_chars"] | |
@connection.insert_fixture({ "tags" => tag_values }, "pg_arrays") | |
assert_equal(PgArray.last.tags, tag_values) | |
end | |
def test_attribute_for_inspect_for_array_field | |
record = PgArray.new { |a| a.ratings = (1..10).to_a } | |
assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]", record.attribute_for_inspect(:ratings)) | |
end | |
def test_attribute_for_inspect_for_array_field_for_large_array | |
record = PgArray.new { |a| a.ratings = (1..11).to_a } | |
assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]", record.attribute_for_inspect(:ratings)) | |
end | |
def test_escaping | |
unknown = 'foo\\",bar,baz,\\' | |
tags = ["hello_#{unknown}"] | |
ar = PgArray.create!(tags: tags) | |
ar.reload | |
assert_equal tags, ar.tags | |
end | |
def test_string_quoting_rules_match_pg_behavior | |
tags = ["", "one{", "two}", %(three"), "four\\", "five ", "six\t", "seven\n", "eight,", "nine", "ten\r", "NULL"] | |
x = PgArray.create!(tags: tags) | |
x.reload | |
assert_not_predicate x, :changed? | |
end | |
def test_quoting_non_standard_delimiters | |
strings = ["hello,", "world;"] | |
oid = ActiveRecord::ConnectionAdapters::PostgreSQL::OID | |
comma_delim = oid::Array.new(ActiveRecord::Type::String.new, ",") | |
semicolon_delim = oid::Array.new(ActiveRecord::Type::String.new, ";") | |
conn = PgArray.connection | |
assert_equal %({"hello,",world;}), conn.type_cast(comma_delim.serialize(strings)) | |
assert_equal %({hello,;"world;"}), conn.type_cast(semicolon_delim.serialize(strings)) | |
end | |
def test_mutate_array | |
x = PgArray.create!(tags: %w(one two)) | |
x.tags << "three" | |
x.save! | |
x.reload | |
assert_equal %w(one two three), x.tags | |
assert_not_predicate x, :changed? | |
end | |
def test_mutate_value_in_array | |
x = PgArray.create!(hstores: [{ a: "a" }, { b: "b" }]) | |
x.hstores.first["a"] = "c" | |
x.save! | |
x.reload | |
assert_equal [{ "a" => "c" }, { "b" => "b" }], x.hstores | |
assert_not_predicate x, :changed? | |
end | |
def test_datetime_with_timezone_awareness | |
tz = "Pacific Time (US & Canada)" | |
in_time_zone tz do | |
PgArray.reset_column_information | |
time_string = Time.current.to_s | |
time = Time.zone.parse(time_string) | |
record = PgArray.new(datetimes: [time_string]) | |
assert_equal [time], record.datetimes | |
assert_equal ActiveSupport::TimeZone[tz], record.datetimes.first.time_zone | |
record.save! | |
record.reload | |
assert_equal [time], record.datetimes | |
assert_equal ActiveSupport::TimeZone[tz], record.datetimes.first.time_zone | |
end | |
end | |
def test_assigning_non_array_value | |
record = PgArray.new(tags: "not-an-array") | |
assert_equal [], record.tags | |
assert_equal "not-an-array", record.tags_before_type_cast | |
assert record.save | |
assert_equal record.tags, record.reload.tags | |
end | |
def test_assigning_empty_string | |
record = PgArray.new(tags: "") | |
assert_equal [], record.tags | |
assert_equal "", record.tags_before_type_cast | |
assert record.save | |
assert_equal record.tags, record.reload.tags | |
end | |
def test_assigning_valid_pg_array_literal | |
record = PgArray.new(tags: "{1,2,3}") | |
assert_equal ["1", "2", "3"], record.tags | |
assert_equal "{1,2,3}", record.tags_before_type_cast | |
assert record.save | |
assert_equal record.tags, record.reload.tags | |
end | |
def test_where_by_attribute_with_array | |
tags = ["black", "blue"] | |
record = PgArray.create!(tags: tags) | |
assert_equal record, PgArray.where(tags: tags).take | |
end | |
def test_uniqueness_validation | |
klass = Class.new(PgArray) do | |
validates_uniqueness_of :tags | |
def self.model_name; ActiveModel::Name.new(PgArray) end | |
end | |
e1 = klass.create("tags" => ["black", "blue"]) | |
assert e1.persisted?, "Saving e1" | |
e2 = klass.create("tags" => ["black", "blue"]) | |
assert_not e2.persisted?, "e2 shouldn't be valid" | |
assert e2.errors[:tags].any?, "Should have errors for tags" | |
assert_equal ["has already been taken"], e2.errors[:tags], "Should have uniqueness message for tags" | |
end | |
def test_encoding_arrays_of_utf8_strings | |
arrays_of_utf8_strings = %w(nový ファイル) | |
assert_equal arrays_of_utf8_strings, @type.deserialize(@type.serialize(arrays_of_utf8_strings)) | |
assert_equal [arrays_of_utf8_strings], @type.deserialize(@type.serialize([arrays_of_utf8_strings])) | |
end | |
def test_precision_is_respected_on_timestamp_columns | |
time = Time.now.change(usec: 123) | |
record = PgArray.create!(timestamps: [time]) | |
assert_equal 123, record.timestamps.first.usec | |
record.reload | |
assert_equal 123, record.timestamps.first.usec | |
end | |
private | |
def assert_cycle(field, array) | |
# test creation | |
x = PgArray.create!(field => array) | |
x.reload | |
assert_equal(array, x.public_send(field)) | |
# test updating | |
x = PgArray.create!(field => []) | |
x.public_send("#{field}=", array) | |
x.save! | |
x.reload | |
assert_equal(array, x.public_send(field)) | |
end | |
end |
# frozen_string_literal: true | |
class Publisher::Article < ActiveRecord::Base | |
has_and_belongs_to_many :magazines | |
has_and_belongs_to_many :tags | |
end |
# frozen_string_literal: true | |
class ARUnit2Model < ActiveRecord::Base | |
self.abstract_class = true | |
end |
# frozen_string_literal: true | |
require_relative "../helper" | |
module Arel | |
module Nodes | |
describe "As" do | |
describe "#as" do | |
it "makes an AS node" do | |
attr = Table.new(:users)[:id] | |
as = attr.as(Arel.sql("foo")) | |
assert_equal attr, as.left | |
assert_equal "foo", as.right | |
end | |
it "converts right to SqlLiteral if a string" do | |
attr = Table.new(:users)[:id] | |
as = attr.as("foo") | |
assert_kind_of Arel::Nodes::SqlLiteral, as.right | |
end | |
end | |
describe "equality" do | |
it "is equal with equal ivars" do | |
array = [As.new("foo", "bar"), As.new("foo", "bar")] | |
assert_equal 1, array.uniq.size | |
end | |
it "is not equal with different ivars" do | |
array = [As.new("foo", "bar"), As.new("foo", "baz")] | |
assert_equal 2, array.uniq.size | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module Arel # :nodoc: all | |
module Nodes | |
class Ascending < Ordering | |
def reverse | |
Descending.new(expr) | |
end | |
def direction | |
:asc | |
end | |
def ascending? | |
true | |
end | |
def descending? | |
false | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../helper" | |
module Arel | |
module Nodes | |
class TestAscending < Arel::Test | |
def test_construct | |
ascending = Ascending.new "zomg" | |
assert_equal "zomg", ascending.expr | |
end | |
def test_reverse | |
ascending = Ascending.new "zomg" | |
descending = ascending.reverse | |
assert_kind_of Descending, descending | |
assert_equal ascending.expr, descending.expr | |
end | |
def test_direction | |
ascending = Ascending.new "zomg" | |
assert_equal :asc, ascending.direction | |
end | |
def test_ascending? | |
ascending = Ascending.new "zomg" | |
assert ascending.ascending? | |
end | |
def test_descending? | |
ascending = Ascending.new "zomg" | |
assert_not ascending.descending? | |
end | |
def test_equality_with_same_ivars | |
array = [Ascending.new("zomg"), Ascending.new("zomg")] | |
assert_equal 1, array.uniq.size | |
end | |
def test_inequality_with_different_ivars | |
array = [Ascending.new("zomg"), Ascending.new("zomg!")] | |
assert_equal 2, array.uniq.size | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/enumerable" | |
module ActiveSupport | |
module Testing | |
module Assertions | |
UNTRACKED = Object.new # :nodoc: | |
# Asserts that an expression is not truthy. Passes if <tt>object</tt> is | |
# +nil+ or +false+. "Truthy" means "considered true in a conditional" | |
# like <tt>if foo</tt>. | |
# | |
# assert_not nil # => true | |
# assert_not false # => true | |
# assert_not 'foo' # => Expected "foo" to be nil or false | |
# | |
# An error message can be specified. | |
# | |
# assert_not foo, 'foo should be false' | |
def assert_not(object, message = nil) | |
message ||= "Expected #{mu_pp(object)} to be nil or false" | |
assert !object, message | |
end | |
# Assertion that the block should not raise an exception. | |
# | |
# Passes if evaluated code in the yielded block raises no exception. | |
# | |
# assert_nothing_raised do | |
# perform_service(param: 'no_exception') | |
# end | |
def assert_nothing_raised | |
yield.tap { assert(true) } | |
rescue => error | |
raise Minitest::UnexpectedError.new(error) | |
end | |
# Test numeric difference between the return value of an expression as a | |
# result of what is evaluated in the yielded block. | |
# | |
# assert_difference 'Article.count' do | |
# post :create, params: { article: {...} } | |
# end | |
# | |
# An arbitrary expression is passed in and evaluated. | |
# | |
# assert_difference 'Article.last.comments(:reload).size' do | |
# post :create, params: { comment: {...} } | |
# end | |
# | |
# An arbitrary positive or negative difference can be specified. | |
# The default is <tt>1</tt>. | |
# | |
# assert_difference 'Article.count', -1 do | |
# post :delete, params: { id: ... } | |
# end | |
# | |
# An array of expressions can also be passed in and evaluated. | |
# | |
# assert_difference [ 'Article.count', 'Post.count' ], 2 do | |
# post :create, params: { article: {...} } | |
# end | |
# | |
# A hash of expressions/numeric differences can also be passed in and evaluated. | |
# | |
# assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do | |
# post :create, params: { article: {...} } | |
# end | |
# | |
# A lambda or a list of lambdas can be passed in and evaluated: | |
# | |
# assert_difference ->{ Article.count }, 2 do | |
# post :create, params: { article: {...} } | |
# end | |
# | |
# assert_difference [->{ Article.count }, ->{ Post.count }], 2 do | |
# post :create, params: { article: {...} } | |
# end | |
# | |
# An error message can be specified. | |
# | |
# assert_difference 'Article.count', -1, 'An Article should be destroyed' do | |
# post :delete, params: { id: ... } | |
# end | |
def assert_difference(expression, *args, &block) | |
expressions = | |
if expression.is_a?(Hash) | |
message = args[0] | |
expression | |
else | |
difference = args[0] || 1 | |
message = args[1] | |
Array(expression).index_with(difference) | |
end | |
exps = expressions.keys.map { |e| | |
e.respond_to?(:call) ? e : lambda { eval(e, block.binding) } | |
} | |
before = exps.map(&:call) | |
retval = _assert_nothing_raised_or_warn("assert_difference", &block) | |
expressions.zip(exps, before) do |(code, diff), exp, before_value| | |
error = "#{code.inspect} didn't change by #{diff}" | |
error = "#{message}.\n#{error}" if message | |
assert_equal(before_value + diff, exp.call, error) | |
end | |
retval | |
end | |
# Assertion that the numeric result of evaluating an expression is not | |
# changed before and after invoking the passed in block. | |
# | |
# assert_no_difference 'Article.count' do | |
# post :create, params: { article: invalid_attributes } | |
# end | |
# | |
# A lambda can be passed in and evaluated. | |
# | |
# assert_no_difference -> { Article.count } do | |
# post :create, params: { article: invalid_attributes } | |
# end | |
# | |
# An error message can be specified. | |
# | |
# assert_no_difference 'Article.count', 'An Article should not be created' do | |
# post :create, params: { article: invalid_attributes } | |
# end | |
# | |
# An array of expressions can also be passed in and evaluated. | |
# | |
# assert_no_difference [ 'Article.count', -> { Post.count } ] do | |
# post :create, params: { article: invalid_attributes } | |
# end | |
def assert_no_difference(expression, message = nil, &block) | |
assert_difference expression, 0, message, &block | |
end | |
# Assertion that the result of evaluating an expression is changed before | |
# and after invoking the passed in block. | |
# | |
# assert_changes 'Status.all_good?' do | |
# post :create, params: { status: { ok: false } } | |
# end | |
# | |
# You can pass the block as a string to be evaluated in the context of | |
# the block. A lambda can be passed for the block as well. | |
# | |
# assert_changes -> { Status.all_good? } do | |
# post :create, params: { status: { ok: false } } | |
# end | |
# | |
# The assertion is useful to test side effects. The passed block can be | |
# anything that can be converted to string with #to_s. | |
# | |
# assert_changes :@object do | |
# @object = 42 | |
# end | |
# | |
# The keyword arguments +:from+ and +:to+ can be given to specify the | |
# expected initial value and the expected value after the block was | |
# executed. | |
# | |
# assert_changes :@object, from: nil, to: :foo do | |
# @object = :foo | |
# end | |
# | |
# An error message can be specified. | |
# | |
# assert_changes -> { Status.all_good? }, 'Expected the status to be bad' do | |
# post :create, params: { status: { incident: true } } | |
# end | |
def assert_changes(expression, message = nil, from: UNTRACKED, to: UNTRACKED, &block) | |
exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) } | |
before = exp.call | |
retval = _assert_nothing_raised_or_warn("assert_changes", &block) | |
unless from == UNTRACKED | |
error = "Expected change from #{from.inspect}" | |
error = "#{message}.\n#{error}" if message | |
assert from === before, error | |
end | |
after = exp.call | |
error = "#{expression.inspect} didn't change" | |
error = "#{error}. It was already #{to}" if before == to | |
error = "#{message}.\n#{error}" if message | |
refute_equal before, after, error | |
unless to == UNTRACKED | |
error = "Expected change to #{to}\n" | |
error = "#{message}.\n#{error}" if message | |
assert to === after, error | |
end | |
retval | |
end | |
# Assertion that the result of evaluating an expression is not changed before | |
# and after invoking the passed in block. | |
# | |
# assert_no_changes 'Status.all_good?' do | |
# post :create, params: { status: { ok: true } } | |
# end | |
# | |
# Provide the optional keyword argument :from to specify the expected | |
# initial value. | |
# | |
# assert_no_changes -> { Status.all_good? }, from: true do | |
# post :create, params: { status: { ok: true } } | |
# end | |
# | |
# An error message can be specified. | |
# | |
# assert_no_changes -> { Status.all_good? }, 'Expected the status to be good' do | |
# post :create, params: { status: { ok: false } } | |
# end | |
def assert_no_changes(expression, message = nil, from: UNTRACKED, &block) | |
exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) } | |
before = exp.call | |
retval = _assert_nothing_raised_or_warn("assert_no_changes", &block) | |
unless from == UNTRACKED | |
error = "Expected initial value of #{from.inspect}" | |
error = "#{message}.\n#{error}" if message | |
assert from === before, error | |
end | |
after = exp.call | |
error = "#{expression.inspect} changed" | |
error = "#{message}.\n#{error}" if message | |
if before.nil? | |
assert_nil after, error | |
else | |
assert_equal before, after, error | |
end | |
retval | |
end | |
private | |
def _assert_nothing_raised_or_warn(assertion, &block) | |
assert_nothing_raised(&block) | |
rescue Minitest::UnexpectedError => e | |
if tagged_logger && tagged_logger.warn? | |
warning = <<~MSG | |
#{self.class} - #{name}: #{e.error.class} raised. | |
If you expected this exception, use `assert_raises` as near to the code that raises as possible. | |
Other block based assertions (e.g. `#{assertion}`) can be used, as long as `assert_raises` is inside their block. | |
MSG | |
tagged_logger.warn warning | |
end | |
raise | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
# Be sure to restart your server when you modify this file. | |
# Version of your assets, change this if you want to expire all your assets. | |
Rails.application.config.assets.version = "1.0" | |
# Add additional assets to the asset load path. | |
# Rails.application.config.assets.paths << Emoji.images_path | |
# Add Yarn node_modules folder to the asset load path. | |
Rails.application.config.assets.paths << Rails.root.join("node_modules") | |
# Precompile additional assets. | |
# application.js, application.css, and all non-JS/CSS in the app/assets | |
# folder are already added. | |
# Rails.application.config.assets.precompile += %w( admin.js admin.css ) |
# frozen_string_literal: true | |
module ActiveRecord | |
module Validations | |
class AssociatedValidator < ActiveModel::EachValidator # :nodoc: | |
def validate_each(record, attribute, value) | |
if Array(value).reject { |r| valid_object?(r) }.any? | |
record.errors.add(attribute, :invalid, **options.merge(value: value)) | |
end | |
end | |
private | |
def valid_object?(record) | |
(record.respond_to?(:marked_for_destruction?) && record.marked_for_destruction?) || record.valid? | |
end | |
end | |
module ClassMethods | |
# Validates whether the associated object or objects are all valid. | |
# Works with any kind of association. | |
# | |
# class Book < ActiveRecord::Base | |
# has_many :pages | |
# belongs_to :library | |
# | |
# validates_associated :pages, :library | |
# end | |
# | |
# WARNING: This validation must not be used on both ends of an association. | |
# Doing so will lead to a circular dependency and cause infinite recursion. | |
# | |
# NOTE: This validation will not fail if the association hasn't been | |
# assigned. If you want to ensure that the association is both present and | |
# guaranteed to be valid, you also need to use | |
# {validates_presence_of}[rdoc-ref:Validations::ClassMethods#validates_presence_of]. | |
# | |
# Configuration options: | |
# | |
# * <tt>:message</tt> - A custom error message (default is: "is invalid"). | |
# * <tt>:on</tt> - Specifies the contexts where this validation is active. | |
# Runs in all validation contexts by default +nil+. You can pass a symbol | |
# or an array of symbols. (e.g. <tt>on: :create</tt> or | |
# <tt>on: :custom_validation_context</tt> or | |
# <tt>on: [:create, :custom_validation_context]</tt>) | |
# * <tt>:if</tt> - Specifies a method, proc, or string to call to determine | |
# if the validation should occur (e.g. <tt>if: :allow_validation</tt>, | |
# or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method, | |
# proc or string should return or evaluate to a +true+ or +false+ value. | |
# * <tt>:unless</tt> - Specifies a method, proc, or string to call to | |
# determine if the validation should not occur (e.g. <tt>unless: :skip_validation</tt>, | |
# or <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>). The | |
# method, proc, or string should return or evaluate to a +true+ or +false+ | |
# value. | |
def validates_associated(*attr_names) | |
validates_with AssociatedValidator, _merge_attributes(attr_names) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Associations | |
class Preloader | |
class Association # :nodoc: | |
class LoaderQuery | |
attr_reader :scope, :association_key_name | |
def initialize(scope, association_key_name) | |
@scope = scope | |
@association_key_name = association_key_name | |
end | |
def eql?(other) | |
association_key_name == other.association_key_name && | |
scope.table_name == other.scope.table_name && | |
scope.values_for_queries == other.scope.values_for_queries | |
end | |
def hash | |
[association_key_name, scope.table_name, scope.values_for_queries].hash | |
end | |
def records_for(loaders) | |
LoaderRecords.new(loaders, self).records | |
end | |
def load_records_in_batch(loaders) | |
raw_records = records_for(loaders) | |
loaders.each do |loader| | |
loader.load_records(raw_records) | |
loader.run | |
end | |
end | |
def load_records_for_keys(keys, &block) | |
scope.where(association_key_name => keys).load(&block) | |
end | |
end | |
class LoaderRecords | |
def initialize(loaders, loader_query) | |
@loader_query = loader_query | |
@loaders = loaders | |
@keys_to_load = Set.new | |
@already_loaded_records_by_key = {} | |
populate_keys_to_load_and_already_loaded_records | |
end | |
def records | |
load_records + already_loaded_records | |
end | |
private | |
attr_reader :loader_query, :loaders, :keys_to_load, :already_loaded_records_by_key | |
def populate_keys_to_load_and_already_loaded_records | |
loaders.each do |loader| | |
loader.owners_by_key.each do |key, owners| | |
if loaded_owner = owners.find { |owner| loader.loaded?(owner) } | |
already_loaded_records_by_key[key] = loader.target_for(loaded_owner) | |
else | |
keys_to_load << key | |
end | |
end | |
end | |
@keys_to_load.subtract(already_loaded_records_by_key.keys) | |
end | |
def load_records | |
loader_query.load_records_for_keys(keys_to_load) do |record| | |
loaders.each { |l| l.set_inverse(record) } | |
end | |
end | |
def already_loaded_records | |
already_loaded_records_by_key.values.flatten | |
end | |
end | |
attr_reader :klass | |
def initialize(klass, owners, reflection, preload_scope, reflection_scope, associate_by_default) | |
@klass = klass | |
@owners = owners.uniq(&:__id__) | |
@reflection = reflection | |
@preload_scope = preload_scope | |
@reflection_scope = reflection_scope | |
@associate = associate_by_default || !preload_scope || preload_scope.empty_scope? | |
@model = owners.first && owners.first.class | |
@run = false | |
end | |
def table_name | |
@klass.table_name | |
end | |
def future_classes | |
if run? | |
[] | |
else | |
[@klass] | |
end | |
end | |
def runnable_loaders | |
[self] | |
end | |
def run? | |
@run | |
end | |
def run | |
return self if run? | |
@run = true | |
records = records_by_owner | |
owners.each do |owner| | |
associate_records_to_owner(owner, records[owner] || []) | |
end if @associate | |
self | |
end | |
def records_by_owner | |
load_records unless defined?(@records_by_owner) | |
@records_by_owner | |
end | |
def preloaded_records | |
load_records unless defined?(@preloaded_records) | |
@preloaded_records | |
end | |
# The name of the key on the associated records | |
def association_key_name | |
reflection.join_primary_key(klass) | |
end | |
def loader_query | |
LoaderQuery.new(scope, association_key_name) | |
end | |
def owners_by_key | |
@owners_by_key ||= owners.each_with_object({}) do |owner, result| | |
key = convert_key(owner[owner_key_name]) | |
(result[key] ||= []) << owner if key | |
end | |
end | |
def loaded?(owner) | |
owner.association(reflection.name).loaded? | |
end | |
def target_for(owner) | |
Array.wrap(owner.association(reflection.name).target) | |
end | |
def scope | |
@scope ||= build_scope | |
end | |
def set_inverse(record) | |
if owners = owners_by_key[convert_key(record[association_key_name])] | |
# Processing only the first owner | |
# because the record is modified but not an owner | |
association = owners.first.association(reflection.name) | |
association.set_inverse_instance(record) | |
end | |
end | |
def load_records(raw_records = nil) | |
# owners can be duplicated when a relation has a collection association join | |
# #compare_by_identity makes such owners different hash keys | |
@records_by_owner = {}.compare_by_identity | |
raw_records ||= loader_query.records_for([self]) | |
@preloaded_records = raw_records.select do |record| | |
assignments = false | |
owners_by_key[convert_key(record[association_key_name])]&.each do |owner| | |
entries = (@records_by_owner[owner] ||= []) | |
if reflection.collection? || entries.empty? | |
entries << record | |
assignments = true | |
end | |
end | |
assignments | |
end | |
end | |
def associate_records_from_unscoped(unscoped_records) | |
return if unscoped_records.nil? || unscoped_records.empty? | |
return if !reflection_scope.empty_scope? | |
return if preload_scope && !preload_scope.empty_scope? | |
return if reflection.collection? | |
unscoped_records.select { |r| r[association_key_name].present? }.each do |record| | |
owners = owners_by_key[convert_key(record[association_key_name])] | |
owners&.each_with_index do |owner, i| | |
association = owner.association(reflection.name) | |
association.target = record | |
if i == 0 # Set inverse on first owner | |
association.set_inverse_instance(record) | |
end | |
end | |
end | |
end | |
private | |
attr_reader :owners, :reflection, :preload_scope, :model | |
# The name of the key on the model which declares the association | |
def owner_key_name | |
reflection.join_foreign_key | |
end | |
def associate_records_to_owner(owner, records) | |
return if loaded?(owner) | |
association = owner.association(reflection.name) | |
if reflection.collection? | |
association.target = records | |
else | |
association.target = records.first | |
end | |
end | |
def key_conversion_required? | |
unless defined?(@key_conversion_required) | |
@key_conversion_required = (association_key_type != owner_key_type) | |
end | |
@key_conversion_required | |
end | |
def convert_key(key) | |
if key_conversion_required? | |
key.to_s | |
else | |
key | |
end | |
end | |
def association_key_type | |
@klass.type_for_attribute(association_key_name).type | |
end | |
def owner_key_type | |
@model.type_for_attribute(owner_key_name).type | |
end | |
def reflection_scope | |
@reflection_scope ||= reflection.join_scopes(klass.arel_table, klass.predicate_builder, klass).inject(klass.unscoped, &:merge!) | |
end | |
def build_scope | |
scope = klass.scope_for_association | |
if reflection.type && !reflection.through_reflection? | |
scope.where!(reflection.type => model.polymorphic_name) | |
end | |
scope.merge!(reflection_scope) unless reflection_scope.empty_scope? | |
if preload_scope && !preload_scope.empty_scope? | |
scope.merge!(preload_scope) | |
end | |
cascade_strict_loading(scope) | |
end | |
def cascade_strict_loading(scope) | |
preload_scope&.strict_loading_value ? scope.strict_loading : scope | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
class PredicateBuilder | |
class AssociationQueryValue # :nodoc: | |
def initialize(associated_table, value) | |
@associated_table = associated_table | |
@value = value | |
end | |
def queries | |
[ associated_table.join_foreign_key => ids ] | |
end | |
private | |
attr_reader :associated_table, :value | |
def ids | |
case value | |
when Relation | |
value.select_values.empty? ? value.select(primary_key) : value | |
when Array | |
value.map { |v| convert_to_id(v) } | |
else | |
convert_to_id(value) | |
end | |
end | |
def primary_key | |
associated_table.join_primary_key | |
end | |
def convert_to_id(value) | |
if value.respond_to?(primary_key) | |
value.public_send(primary_key) | |
else | |
value | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
class AssociationRelation < Relation # :nodoc: | |
def initialize(klass, association, **) | |
super(klass) | |
@association = association | |
end | |
def proxy_association | |
@association | |
end | |
def ==(other) | |
other == records | |
end | |
%w(insert insert_all insert! insert_all! upsert upsert_all).each do |method| | |
class_eval <<~RUBY | |
def #{method}(attributes, **kwargs) | |
if @association.reflection.through_reflection? | |
raise ArgumentError, "Bulk insert or upsert is currently not supported for has_many through association" | |
end | |
scoping { klass.#{method}(attributes, **kwargs) } | |
end | |
RUBY | |
end | |
private | |
def _new(attributes, &block) | |
@association.build(attributes, &block) | |
end | |
def _create(attributes, &block) | |
@association.create(attributes, &block) | |
end | |
def _create!(attributes, &block) | |
@association.create!(attributes, &block) | |
end | |
def exec_queries | |
super do |record| | |
@association.set_inverse_instance_from_queries(record) | |
yield record if block_given? | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Associations | |
class AssociationScope # :nodoc: | |
def self.scope(association) | |
INSTANCE.scope(association) | |
end | |
def self.create(&block) | |
block ||= lambda { |val| val } | |
new(block) | |
end | |
def initialize(value_transformation) | |
@value_transformation = value_transformation | |
end | |
INSTANCE = create | |
def scope(association) | |
klass = association.klass | |
reflection = association.reflection | |
scope = klass.unscoped | |
owner = association.owner | |
chain = get_chain(reflection, association, scope.alias_tracker) | |
scope.extending! reflection.extensions | |
scope = add_constraints(scope, owner, chain) | |
scope.limit!(1) unless reflection.collection? | |
scope | |
end | |
def self.get_bind_values(owner, chain) | |
binds = [] | |
last_reflection = chain.last | |
binds << last_reflection.join_id_for(owner) | |
if last_reflection.type | |
binds << owner.class.polymorphic_name | |
end | |
chain.each_cons(2).each do |reflection, next_reflection| | |
if reflection.type | |
binds << next_reflection.klass.polymorphic_name | |
end | |
end | |
binds | |
end | |
private | |
attr_reader :value_transformation | |
def join(table, constraint) | |
Arel::Nodes::LeadingJoin.new(table, Arel::Nodes::On.new(constraint)) | |
end | |
def last_chain_scope(scope, reflection, owner) | |
primary_key = reflection.join_primary_key | |
foreign_key = reflection.join_foreign_key | |
table = reflection.aliased_table | |
value = transform_value(owner[foreign_key]) | |
scope = apply_scope(scope, table, primary_key, value) | |
if reflection.type | |
polymorphic_type = transform_value(owner.class.polymorphic_name) | |
scope = apply_scope(scope, table, reflection.type, polymorphic_type) | |
end | |
scope | |
end | |
def transform_value(value) | |
value_transformation.call(value) | |
end | |
def next_chain_scope(scope, reflection, next_reflection) | |
primary_key = reflection.join_primary_key | |
foreign_key = reflection.join_foreign_key | |
table = reflection.aliased_table | |
foreign_table = next_reflection.aliased_table | |
constraint = table[primary_key].eq(foreign_table[foreign_key]) | |
if reflection.type | |
value = transform_value(next_reflection.klass.polymorphic_name) | |
scope = apply_scope(scope, table, reflection.type, value) | |
end | |
scope.joins!(join(foreign_table, constraint)) | |
end | |
class ReflectionProxy < SimpleDelegator # :nodoc: | |
attr_reader :aliased_table | |
def initialize(reflection, aliased_table) | |
super(reflection) | |
@aliased_table = aliased_table | |
end | |
def all_includes; nil; end | |
end | |
def get_chain(reflection, association, tracker) | |
name = reflection.name | |
chain = [Reflection::RuntimeReflection.new(reflection, association)] | |
reflection.chain.drop(1).each do |refl| | |
aliased_table = tracker.aliased_table_for(refl.klass.arel_table) do | |
refl.alias_candidate(name) | |
end | |
chain << ReflectionProxy.new(refl, aliased_table) | |
end | |
chain | |
end | |
def add_constraints(scope, owner, chain) | |
scope = last_chain_scope(scope, chain.last, owner) | |
chain.each_cons(2) do |reflection, next_reflection| | |
scope = next_chain_scope(scope, reflection, next_reflection) | |
end | |
chain_head = chain.first | |
chain.reverse_each do |reflection| | |
reflection.constraints.each do |scope_chain_item| | |
item = eval_scope(reflection, scope_chain_item, owner) | |
if scope_chain_item == chain_head.scope | |
scope.merge! item.except(:where, :includes, :unscope, :order) | |
elsif !item.references_values.empty? | |
scope.merge! item.only(:joins, :left_outer_joins) | |
associations = item.eager_load_values | item.includes_values | |
unless associations.empty? | |
scope.joins! item.construct_join_dependency(associations, Arel::Nodes::OuterJoin) | |
end | |
end | |
reflection.all_includes do | |
scope.includes_values |= item.includes_values | |
end | |
scope.unscope!(*item.unscope_values) | |
scope.where_clause += item.where_clause | |
scope.order_values = item.order_values | scope.order_values | |
end | |
end | |
scope | |
end | |
def apply_scope(scope, table, key, value) | |
if scope.table == table | |
scope.where!(key => value) | |
else | |
scope.where!(table.name => { key => value }) | |
end | |
end | |
def eval_scope(reflection, scope, owner) | |
relation = reflection.build_scope(reflection.aliased_table) | |
relation.instance_exec(owner, &scope) || relation | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/topic" | |
require "models/reply" | |
require "models/human" | |
require "models/interest" | |
class AssociationValidationTest < ActiveRecord::TestCase | |
fixtures :topics | |
repair_validations(Topic, Reply) | |
def test_validates_associated_many | |
Topic.validates_associated(:replies) | |
Reply.validates_presence_of(:content) | |
t = Topic.create("title" => "uhohuhoh", "content" => "whatever") | |
t.replies << [r = Reply.new("title" => "A reply"), r2 = Reply.new("title" => "Another reply", "content" => "non-empty"), r3 = Reply.new("title" => "Yet another reply"), r4 = Reply.new("title" => "The last reply", "content" => "non-empty")] | |
assert_not_predicate t, :valid? | |
assert_predicate t.errors[:replies], :any? | |
assert_equal 1, r.errors.count # make sure all associated objects have been validated | |
assert_equal 0, r2.errors.count | |
assert_equal 1, r3.errors.count | |
assert_equal 0, r4.errors.count | |
r.content = r3.content = "non-empty" | |
assert_predicate t, :valid? | |
end | |
def test_validates_associated_one | |
Reply.validates :topic, associated: true | |
Topic.validates_presence_of(:content) | |
r = Reply.new("title" => "A reply", "content" => "with content!") | |
r.topic = Topic.create("title" => "uhohuhoh") | |
assert_not_predicate r, :valid? | |
assert_predicate r.errors[:topic], :any? | |
r.topic.content = "non-empty" | |
assert_predicate r, :valid? | |
end | |
def test_validates_associated_marked_for_destruction | |
Topic.validates_associated(:replies) | |
Reply.validates_presence_of(:content) | |
t = Topic.new | |
t.replies << Reply.new | |
assert_predicate t, :invalid? | |
t.replies.first.mark_for_destruction | |
assert_predicate t, :valid? | |
end | |
def test_validates_associated_without_marked_for_destruction | |
reply = Class.new do | |
def valid? | |
true | |
end | |
end | |
Topic.validates_associated(:replies) | |
t = Topic.new | |
t.define_singleton_method(:replies) { [reply.new] } | |
assert_predicate t, :valid? | |
end | |
def test_validates_associated_with_custom_message_using_quotes | |
Reply.validates_associated :topic, message: "This string contains 'single' and \"double\" quotes" | |
Topic.validates_presence_of :content | |
r = Reply.create("title" => "A reply", "content" => "with content!") | |
r.topic = Topic.create("title" => "uhohuhoh") | |
assert_not_operator r, :valid? | |
assert_equal ["This string contains 'single' and \"double\" quotes"], r.errors[:topic] | |
end | |
def test_validates_associated_missing | |
Reply.validates_presence_of(:topic) | |
r = Reply.create("title" => "A reply", "content" => "with content!") | |
assert_not_predicate r, :valid? | |
assert_predicate r.errors[:topic], :any? | |
r.topic = Topic.first | |
assert_predicate r, :valid? | |
end | |
def test_validates_presence_of_belongs_to_association__parent_is_new_record | |
repair_validations(Interest) do | |
# Note that Interest and Human have the :inverse_of option set | |
Interest.validates_presence_of(:human) | |
human = Human.new(name: "John") | |
interest = human.interests.build(topic: "Airplanes") | |
assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a human object associated" | |
end | |
end | |
def test_validates_presence_of_belongs_to_association__existing_parent | |
repair_validations(Interest) do | |
Interest.validates_presence_of(:human) | |
human = Human.create!(name: "John") | |
interest = human.interests.build(topic: "Airplanes") | |
assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a human object associated" | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
class AssociationNotFoundError < ConfigurationError # :nodoc: | |
attr_reader :record, :association_name | |
def initialize(record = nil, association_name = nil) | |
@record = record | |
@association_name = association_name | |
if record && association_name | |
super("Association named '#{association_name}' was not found on #{record.class.name}; perhaps you misspelled it?") | |
else | |
super("Association was not found.") | |
end | |
end | |
if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) | |
include DidYouMean::Correctable | |
def corrections | |
if record && association_name | |
@corrections ||= begin | |
maybe_these = record.class.reflections.keys | |
DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(association_name) | |
end | |
else | |
[] | |
end | |
end | |
end | |
end | |
class InverseOfAssociationNotFoundError < ActiveRecordError # :nodoc: | |
attr_reader :reflection, :associated_class | |
def initialize(reflection = nil, associated_class = nil) | |
if reflection | |
@reflection = reflection | |
@associated_class = associated_class.nil? ? reflection.klass : associated_class | |
super("Could not find the inverse association for #{reflection.name} (#{reflection.options[:inverse_of].inspect} in #{associated_class.nil? ? reflection.class_name : associated_class.name})") | |
else | |
super("Could not find the inverse association.") | |
end | |
end | |
if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) | |
include DidYouMean::Correctable | |
def corrections | |
if reflection && associated_class | |
@corrections ||= begin | |
maybe_these = associated_class.reflections.keys | |
DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(reflection.options[:inverse_of].to_s) | |
end | |
else | |
[] | |
end | |
end | |
end | |
end | |
class InverseOfAssociationRecursiveError < ActiveRecordError # :nodoc: | |
attr_reader :reflection | |
def initialize(reflection = nil) | |
if reflection | |
@reflection = reflection | |
super("Inverse association #{reflection.name} (#{reflection.options[:inverse_of].inspect} in #{reflection.class_name}) is recursive.") | |
else | |
super("Inverse association is recursive.") | |
end | |
end | |
end | |
class HasManyThroughAssociationNotFoundError < ActiveRecordError # :nodoc: | |
attr_reader :owner_class, :reflection | |
def initialize(owner_class = nil, reflection = nil) | |
if owner_class && reflection | |
@owner_class = owner_class | |
@reflection = reflection | |
super("Could not find the association #{reflection.options[:through].inspect} in model #{owner_class.name}") | |
else | |
super("Could not find the association.") | |
end | |
end | |
if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) | |
include DidYouMean::Correctable | |
def corrections | |
if owner_class && reflection | |
@corrections ||= begin | |
maybe_these = owner_class.reflections.keys | |
maybe_these -= [reflection.name.to_s] # remove failing reflection | |
DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(reflection.options[:through].to_s) | |
end | |
else | |
[] | |
end | |
end | |
end | |
end | |
class HasManyThroughAssociationPolymorphicSourceError < ActiveRecordError # :nodoc: | |
def initialize(owner_class_name = nil, reflection = nil, source_reflection = nil) | |
if owner_class_name && reflection && source_reflection | |
super("Cannot have a has_many :through association '#{owner_class_name}##{reflection.name}' on the polymorphic object '#{source_reflection.class_name}##{source_reflection.name}' without 'source_type'. Try adding 'source_type: \"#{reflection.name.to_s.classify}\"' to 'has_many :through' definition.") | |
else | |
super("Cannot have a has_many :through association.") | |
end | |
end | |
end | |
class HasManyThroughAssociationPolymorphicThroughError < ActiveRecordError # :nodoc: | |
def initialize(owner_class_name = nil, reflection = nil) | |
if owner_class_name && reflection | |
super("Cannot have a has_many :through association '#{owner_class_name}##{reflection.name}' which goes through the polymorphic association '#{owner_class_name}##{reflection.through_reflection.name}'.") | |
else | |
super("Cannot have a has_many :through association.") | |
end | |
end | |
end | |
class HasManyThroughAssociationPointlessSourceTypeError < ActiveRecordError # :nodoc: | |
def initialize(owner_class_name = nil, reflection = nil, source_reflection = nil) | |
if owner_class_name && reflection && source_reflection | |
super("Cannot have a has_many :through association '#{owner_class_name}##{reflection.name}' with a :source_type option if the '#{reflection.through_reflection.class_name}##{source_reflection.name}' is not polymorphic. Try removing :source_type on your association.") | |
else | |
super("Cannot have a has_many :through association.") | |
end | |
end | |
end | |
class HasOneThroughCantAssociateThroughCollection < ActiveRecordError # :nodoc: | |
def initialize(owner_class_name = nil, reflection = nil, through_reflection = nil) | |
if owner_class_name && reflection && through_reflection | |
super("Cannot have a has_one :through association '#{owner_class_name}##{reflection.name}' where the :through association '#{owner_class_name}##{through_reflection.name}' is a collection. Specify a has_one or belongs_to association in the :through option instead.") | |
else | |
super("Cannot have a has_one :through association.") | |
end | |
end | |
end | |
class HasOneAssociationPolymorphicThroughError < ActiveRecordError # :nodoc: | |
def initialize(owner_class_name = nil, reflection = nil) | |
if owner_class_name && reflection | |
super("Cannot have a has_one :through association '#{owner_class_name}##{reflection.name}' which goes through the polymorphic association '#{owner_class_name}##{reflection.through_reflection.name}'.") | |
else | |
super("Cannot have a has_one :through association.") | |
end | |
end | |
end | |
class HasManyThroughSourceAssociationNotFoundError < ActiveRecordError # :nodoc: | |
def initialize(reflection = nil) | |
if reflection | |
through_reflection = reflection.through_reflection | |
source_reflection_names = reflection.source_reflection_names | |
source_associations = reflection.through_reflection.klass._reflections.keys | |
super("Could not find the source association(s) #{source_reflection_names.collect(&:inspect).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ')} in model #{through_reflection.klass}. Try 'has_many #{reflection.name.inspect}, :through => #{through_reflection.name.inspect}, :source => <name>'. Is it one of #{source_associations.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ')}?") | |
else | |
super("Could not find the source association(s).") | |
end | |
end | |
end | |
class HasManyThroughOrderError < ActiveRecordError # :nodoc: | |
def initialize(owner_class_name = nil, reflection = nil, through_reflection = nil) | |
if owner_class_name && reflection && through_reflection | |
super("Cannot have a has_many :through association '#{owner_class_name}##{reflection.name}' which goes through '#{owner_class_name}##{through_reflection.name}' before the through association is defined.") | |
else | |
super("Cannot have a has_many :through association before the through association is defined.") | |
end | |
end | |
end | |
class ThroughCantAssociateThroughHasOneOrManyReflection < ActiveRecordError # :nodoc: | |
def initialize(owner = nil, reflection = nil) | |
if owner && reflection | |
super("Cannot modify association '#{owner.class.name}##{reflection.name}' because the source reflection class '#{reflection.source_reflection.class_name}' is associated to '#{reflection.through_reflection.class_name}' via :#{reflection.source_reflection.macro}.") | |
else | |
super("Cannot modify association.") | |
end | |
end | |
end | |
class AmbiguousSourceReflectionForThroughAssociation < ActiveRecordError # :nodoc: | |
def initialize(klass, macro, association_name, options, possible_sources) | |
example_options = options.dup | |
example_options[:source] = possible_sources.first | |
super("Ambiguous source reflection for through association. Please " \ | |
"specify a :source directive on your declaration like:\n" \ | |
"\n" \ | |
" class #{klass} < ActiveRecord::Base\n" \ | |
" #{macro} :#{association_name}, #{example_options}\n" \ | |
" end" | |
) | |
end | |
end | |
class HasManyThroughCantAssociateThroughHasOneOrManyReflection < ThroughCantAssociateThroughHasOneOrManyReflection # :nodoc: | |
end | |
class HasOneThroughCantAssociateThroughHasOneOrManyReflection < ThroughCantAssociateThroughHasOneOrManyReflection # :nodoc: | |
end | |
class ThroughNestedAssociationsAreReadonly < ActiveRecordError # :nodoc: | |
def initialize(owner = nil, reflection = nil) | |
if owner && reflection | |
super("Cannot modify association '#{owner.class.name}##{reflection.name}' because it goes through more than one other association.") | |
else | |
super("Through nested associations are read-only.") | |
end | |
end | |
end | |
class HasManyThroughNestedAssociationsAreReadonly < ThroughNestedAssociationsAreReadonly # :nodoc: | |
end | |
class HasOneThroughNestedAssociationsAreReadonly < ThroughNestedAssociationsAreReadonly # :nodoc: | |
end | |
# This error is raised when trying to eager load a polymorphic association using a JOIN. | |
# Eager loading polymorphic associations is only possible with | |
# {ActiveRecord::Relation#preload}[rdoc-ref:QueryMethods#preload]. | |
class EagerLoadPolymorphicError < ActiveRecordError | |
def initialize(reflection = nil) | |
if reflection | |
super("Cannot eagerly load the polymorphic association #{reflection.name.inspect}") | |
else | |
super("Eager load polymorphic error.") | |
end | |
end | |
end | |
# This error is raised when trying to destroy a parent instance in N:1 or 1:1 associations | |
# (has_many, has_one) when there is at least 1 child associated instance. | |
# ex: if @project.tasks.size > 0, DeleteRestrictionError will be raised when trying to destroy @project | |
class DeleteRestrictionError < ActiveRecordError # :nodoc: | |
def initialize(name = nil) | |
if name | |
super("Cannot delete record because of dependent #{name}") | |
else | |
super("Delete restriction error.") | |
end | |
end | |
end | |
# See ActiveRecord::Associations::ClassMethods for documentation. | |
module Associations # :nodoc: | |
extend ActiveSupport::Autoload | |
extend ActiveSupport::Concern | |
# These classes will be loaded when associations are created. | |
# So there is no need to eager load them. | |
autoload :Association | |
autoload :SingularAssociation | |
autoload :CollectionAssociation | |
autoload :ForeignAssociation | |
autoload :CollectionProxy | |
autoload :ThroughAssociation | |
module Builder # :nodoc: | |
autoload :Association, "active_record/associations/builder/association" | |
autoload :SingularAssociation, "active_record/associations/builder/singular_association" | |
autoload :CollectionAssociation, "active_record/associations/builder/collection_association" | |
autoload :BelongsTo, "active_record/associations/builder/belongs_to" | |
autoload :HasOne, "active_record/associations/builder/has_one" | |
autoload :HasMany, "active_record/associations/builder/has_many" | |
autoload :HasAndBelongsToMany, "active_record/associations/builder/has_and_belongs_to_many" | |
end | |
eager_autoload do | |
autoload :BelongsToAssociation | |
autoload :BelongsToPolymorphicAssociation | |
autoload :HasManyAssociation | |
autoload :HasManyThroughAssociation | |
autoload :HasOneAssociation | |
autoload :HasOneThroughAssociation | |
autoload :Preloader | |
autoload :JoinDependency | |
autoload :AssociationScope | |
autoload :DisableJoinsAssociationScope | |
autoload :AliasTracker | |
end | |
def self.eager_load! | |
super | |
Preloader.eager_load! | |
JoinDependency.eager_load! | |
end | |
# Returns the association instance for the given name, instantiating it if it doesn't already exist | |
def association(name) # :nodoc: | |
association = association_instance_get(name) | |
if association.nil? | |
unless reflection = self.class._reflect_on_association(name) | |
raise AssociationNotFoundError.new(self, name) | |
end | |
association = reflection.association_class.new(self, reflection) | |
association_instance_set(name, association) | |
end | |
association | |
end | |
def association_cached?(name) # :nodoc: | |
@association_cache.key?(name) | |
end | |
def initialize_dup(*) # :nodoc: | |
@association_cache = {} | |
super | |
end | |
private | |
def init_internals | |
@association_cache = {} | |
super | |
end | |
# Returns the specified association instance if it exists, +nil+ otherwise. | |
def association_instance_get(name) | |
@association_cache[name] | |
end | |
# Set the specified association instance. | |
def association_instance_set(name, association) | |
@association_cache[name] = association | |
end | |
# \Associations are a set of macro-like class methods for tying objects together through | |
# foreign keys. They express relationships like "Project has one Project Manager" | |
# or "Project belongs to a Portfolio". Each macro adds a number of methods to the | |
# class which are specialized according to the collection or association symbol and the | |
# options hash. It works much the same way as Ruby's own <tt>attr*</tt> | |
# methods. | |
# | |
# class Project < ActiveRecord::Base | |
# belongs_to :portfolio | |
# has_one :project_manager | |
# has_many :milestones | |
# has_and_belongs_to_many :categories | |
# end | |
# | |
# The project class now has the following methods (and more) to ease the traversal and | |
# manipulation of its relationships: | |
# * <tt>Project#portfolio</tt>, <tt>Project#portfolio=(portfolio)</tt>, <tt>Project#reload_portfolio</tt> | |
# * <tt>Project#project_manager</tt>, <tt>Project#project_manager=(project_manager)</tt>, <tt>Project#reload_project_manager</tt> | |
# * <tt>Project#milestones.empty?</tt>, <tt>Project#milestones.size</tt>, <tt>Project#milestones</tt>, <tt>Project#milestones<<(milestone)</tt>, | |
# <tt>Project#milestones.delete(milestone)</tt>, <tt>Project#milestones.destroy(milestone)</tt>, <tt>Project#milestones.find(milestone_id)</tt>, | |
# <tt>Project#milestones.build</tt>, <tt>Project#milestones.create</tt> | |
# * <tt>Project#categories.empty?</tt>, <tt>Project#categories.size</tt>, <tt>Project#categories</tt>, <tt>Project#categories<<(category1)</tt>, | |
# <tt>Project#categories.delete(category1)</tt>, <tt>Project#categories.destroy(category1)</tt> | |
# | |
# === A word of warning | |
# | |
# Don't create associations that have the same name as {instance methods}[rdoc-ref:ActiveRecord::Core] of | |
# <tt>ActiveRecord::Base</tt>. Since the association adds a method with that name to | |
# its model, using an association with the same name as one provided by <tt>ActiveRecord::Base</tt> will override the method inherited through <tt>ActiveRecord::Base</tt> and will break things. | |
# For instance, +attributes+ and +connection+ would be bad choices for association names, because those names already exist in the list of <tt>ActiveRecord::Base</tt> instance methods. | |
# | |
# == Auto-generated methods | |
# See also Instance Public methods below for more details. | |
# | |
# === Singular associations (one-to-one) | |
# | | belongs_to | | |
# generated methods | belongs_to | :polymorphic | has_one | |
# ----------------------------------+------------+--------------+--------- | |
# other | X | X | X | |
# other=(other) | X | X | X | |
# build_other(attributes={}) | X | | X | |
# create_other(attributes={}) | X | | X | |
# create_other!(attributes={}) | X | | X | |
# reload_other | X | X | X | |
# other_changed? | X | X | | |
# other_previously_changed? | X | X | | |
# | |
# === Collection associations (one-to-many / many-to-many) | |
# | | | has_many | |
# generated methods | habtm | has_many | :through | |
# ----------------------------------+-------+----------+---------- | |
# others | X | X | X | |
# others=(other,other,...) | X | X | X | |
# other_ids | X | X | X | |
# other_ids=(id,id,...) | X | X | X | |
# others<< | X | X | X | |
# others.push | X | X | X | |
# others.concat | X | X | X | |
# others.build(attributes={}) | X | X | X | |
# others.create(attributes={}) | X | X | X | |
# others.create!(attributes={}) | X | X | X | |
# others.size | X | X | X | |
# others.length | X | X | X | |
# others.count | X | X | X | |
# others.sum(*args) | X | X | X | |
# others.empty? | X | X | X | |
# others.clear | X | X | X | |
# others.delete(other,other,...) | X | X | X | |
# others.delete_all | X | X | X | |
# others.destroy(other,other,...) | X | X | X | |
# others.destroy_all | X | X | X | |
# others.find(*args) | X | X | X | |
# others.exists? | X | X | X | |
# others.distinct | X | X | X | |
# others.reset | X | X | X | |
# others.reload | X | X | X | |
# | |
# === Overriding generated methods | |
# | |
# Association methods are generated in a module included into the model | |
# class, making overrides easy. The original generated method can thus be | |
# called with +super+: | |
# | |
# class Car < ActiveRecord::Base | |
# belongs_to :owner | |
# belongs_to :old_owner | |
# | |
# def owner=(new_owner) | |
# self.old_owner = self.owner | |
# super | |
# end | |
# end | |
# | |
# The association methods module is included immediately after the | |
# generated attributes methods module, meaning an association will | |
# override the methods for an attribute with the same name. | |
# | |
# == Cardinality and associations | |
# | |
# Active Record associations can be used to describe one-to-one, one-to-many, and many-to-many | |
# relationships between models. Each model uses an association to describe its role in | |
# the relation. The #belongs_to association is always used in the model that has | |
# the foreign key. | |
# | |
# === One-to-one | |
# | |
# Use #has_one in the base, and #belongs_to in the associated model. | |
# | |
# class Employee < ActiveRecord::Base | |
# has_one :office | |
# end | |
# class Office < ActiveRecord::Base | |
# belongs_to :employee # foreign key - employee_id | |
# end | |
# | |
# === One-to-many | |
# | |
# Use #has_many in the base, and #belongs_to in the associated model. | |
# | |
# class Manager < ActiveRecord::Base | |
# has_many :employees | |
# end | |
# class Employee < ActiveRecord::Base | |
# belongs_to :manager # foreign key - manager_id | |
# end | |
# | |
# === Many-to-many | |
# | |
# There are two ways to build a many-to-many relationship. | |
# | |
# The first way uses a #has_many association with the <tt>:through</tt> option and a join model, so | |
# there are two stages of associations. | |
# | |
# class Assignment < ActiveRecord::Base | |
# belongs_to :programmer # foreign key - programmer_id | |
# belongs_to :project # foreign key - project_id | |
# end | |
# class Programmer < ActiveRecord::Base | |
# has_many :assignments | |
# has_many :projects, through: :assignments | |
# end | |
# class Project < ActiveRecord::Base | |
# has_many :assignments | |
# has_many :programmers, through: :assignments | |
# end | |
# | |
# For the second way, use #has_and_belongs_to_many in both models. This requires a join table | |
# that has no corresponding model or primary key. | |
# | |
# class Programmer < ActiveRecord::Base | |
# has_and_belongs_to_many :projects # foreign keys in the join table | |
# end | |
# class Project < ActiveRecord::Base | |
# has_and_belongs_to_many :programmers # foreign keys in the join table | |
# end | |
# | |
# Choosing which way to build a many-to-many relationship is not always simple. | |
# If you need to work with the relationship model as its own entity, | |
# use #has_many <tt>:through</tt>. Use #has_and_belongs_to_many when working with legacy schemas or when | |
# you never work directly with the relationship itself. | |
# | |
# == Is it a #belongs_to or #has_one association? | |
# | |
# Both express a 1-1 relationship. The difference is mostly where to place the foreign | |
# key, which goes on the table for the class declaring the #belongs_to relationship. | |
# | |
# class User < ActiveRecord::Base | |
# # I reference an account. | |
# belongs_to :account | |
# end | |
# | |
# class Account < ActiveRecord::Base | |
# # One user references me. | |
# has_one :user | |
# end | |
# | |
# The tables for these classes could look something like: | |
# | |
# CREATE TABLE users ( | |
# id bigint NOT NULL auto_increment, | |
# account_id bigint default NULL, | |
# name varchar default NULL, | |
# PRIMARY KEY (id) | |
# ) | |
# | |
# CREATE TABLE accounts ( | |
# id bigint NOT NULL auto_increment, | |
# name varchar default NULL, | |
# PRIMARY KEY (id) | |
# ) | |
# | |
# == Unsaved objects and associations | |
# | |
# You can manipulate objects and associations before they are saved to the database, but | |
# there is some special behavior you should be aware of, mostly involving the saving of | |
# associated objects. | |
# | |
# You can set the <tt>:autosave</tt> option on a #has_one, #belongs_to, | |
# #has_many, or #has_and_belongs_to_many association. Setting it | |
# to +true+ will _always_ save the members, whereas setting it to +false+ will | |
# _never_ save the members. More details about <tt>:autosave</tt> option is available at | |
# AutosaveAssociation. | |
# | |
# === One-to-one associations | |
# | |
# * Assigning an object to a #has_one association automatically saves that object and | |
# the object being replaced (if there is one), in order to update their foreign | |
# keys - except if the parent object is unsaved (<tt>new_record? == true</tt>). | |
# * If either of these saves fail (due to one of the objects being invalid), an | |
# ActiveRecord::RecordNotSaved exception is raised and the assignment is | |
# cancelled. | |
# * If you wish to assign an object to a #has_one association without saving it, | |
# use the <tt>#build_association</tt> method (documented below). The object being | |
# replaced will still be saved to update its foreign key. | |
# * Assigning an object to a #belongs_to association does not save the object, since | |
# the foreign key field belongs on the parent. It does not save the parent either. | |
# | |
# === Collections | |
# | |
# * Adding an object to a collection (#has_many or #has_and_belongs_to_many) automatically | |
# saves that object, except if the parent object (the owner of the collection) is not yet | |
# stored in the database. | |
# * If saving any of the objects being added to a collection (via <tt>push</tt> or similar) | |
# fails, then <tt>push</tt> returns +false+. | |
# * If saving fails while replacing the collection (via <tt>association=</tt>), an | |
# ActiveRecord::RecordNotSaved exception is raised and the assignment is | |
# cancelled. | |
# * You can add an object to a collection without automatically saving it by using the | |
# <tt>collection.build</tt> method (documented below). | |
# * All unsaved (<tt>new_record? == true</tt>) members of the collection are automatically | |
# saved when the parent is saved. | |
# | |
# == Customizing the query | |
# | |
# \Associations are built from <tt>Relation</tt> objects, and you can use the Relation syntax | |
# to customize them. For example, to add a condition: | |
# | |
# class Blog < ActiveRecord::Base | |
# has_many :published_posts, -> { where(published: true) }, class_name: 'Post' | |
# end | |
# | |
# Inside the <tt>-> { ... }</tt> block you can use all of the usual Relation methods. | |
# | |
# === Accessing the owner object | |
# | |
# Sometimes it is useful to have access to the owner object when building the query. The owner | |
# is passed as a parameter to the block. For example, the following association would find all | |
# events that occur on the user's birthday: | |
# | |
# class User < ActiveRecord::Base | |
# has_many :birthday_events, ->(user) { where(starts_on: user.birthday) }, class_name: 'Event' | |
# end | |
# | |
# Note: Joining, eager loading, and preloading of these associations is not possible. | |
# These operations happen before instance creation and the scope will be called with a +nil+ argument. | |
# | |
# == Association callbacks | |
# | |
# Similar to the normal callbacks that hook into the life cycle of an Active Record object, | |
# you can also define callbacks that get triggered when you add an object to or remove an | |
# object from an association collection. | |
# | |
# class Firm < ActiveRecord::Base | |
# has_many :clients, | |
# dependent: :destroy, | |
# after_add: :congratulate_client, | |
# after_remove: :log_after_remove | |
# | |
# def congratulate_client(record) | |
# # ... | |
# end | |
# | |
# def log_after_remove(record) | |
# # ... | |
# end | |
# | |
# It's possible to stack callbacks by passing them as an array. Example: | |
# | |
# class Firm < ActiveRecord::Base | |
# has_many :clients, | |
# dependent: :destroy, | |
# after_add: [:congratulate_client, -> (firm, record) { firm.log << "after_adding#{record.id}" }], | |
# after_remove: :log_after_remove | |
# end | |
# | |
# Possible callbacks are: +before_add+, +after_add+, +before_remove+, and +after_remove+. | |
# | |
# If any of the +before_add+ callbacks throw an exception, the object will not be | |
# added to the collection. | |
# | |
# Similarly, if any of the +before_remove+ callbacks throw an exception, the object | |
# will not be removed from the collection. | |
# | |
# Note: To trigger remove callbacks, you must use +destroy+ / +destroy_all+ methods. For example: | |
# | |
# * <tt>firm.clients.destroy(client)</tt> | |
# * <tt>firm.clients.destroy(*clients)</tt> | |
# * <tt>firm.clients.destroy_all</tt> | |
# | |
# +delete+ / +delete_all+ methods like the following do *not* trigger remove callbacks: | |
# | |
# * <tt>firm.clients.delete(client)</tt> | |
# * <tt>firm.clients.delete(*clients)</tt> | |
# * <tt>firm.clients.delete_all</tt> | |
# | |
# == Association extensions | |
# | |
# The proxy objects that control the access to associations can be extended through anonymous | |
# modules. This is especially beneficial for adding new finders, creators, and other | |
# factory-type methods that are only used as part of this association. | |
# | |
# class Account < ActiveRecord::Base | |
# has_many :people do | |
# def find_or_create_by_name(name) | |
# first_name, last_name = name.split(" ", 2) | |
# find_or_create_by(first_name: first_name, last_name: last_name) | |
# end | |
# end | |
# end | |
# | |
# person = Account.first.people.find_or_create_by_name("David Heinemeier Hansson") | |
# person.first_name # => "David" | |
# person.last_name # => "Heinemeier Hansson" | |
# | |
# If you need to share the same extensions between many associations, you can use a named | |
# extension module. | |
# | |
# module FindOrCreateByNameExtension | |
# def find_or_create_by_name(name) | |
# first_name, last_name = name.split(" ", 2) | |
# find_or_create_by(first_name: first_name, last_name: last_name) | |
# end | |
# end | |
# | |
# class Account < ActiveRecord::Base | |
# has_many :people, -> { extending FindOrCreateByNameExtension } | |
# end | |
# | |
# class Company < ActiveRecord::Base | |
# has_many :people, -> { extending FindOrCreateByNameExtension } | |
# end | |
# | |
# Some extensions can only be made to work with knowledge of the association's internals. | |
# Extensions can access relevant state using the following methods (where +items+ is the | |
# name of the association): | |
# | |
# * <tt>record.association(:items).owner</tt> - Returns the object the association is part of. | |
# * <tt>record.association(:items).reflection</tt> - Returns the reflection object that describes the association. | |
# * <tt>record.association(:items).target</tt> - Returns the associated object for #belongs_to and #has_one, or | |
# the collection of associated objects for #has_many and #has_and_belongs_to_many. | |
# | |
# However, inside the actual extension code, you will not have access to the <tt>record</tt> as | |
# above. In this case, you can access <tt>proxy_association</tt>. For example, | |
# <tt>record.association(:items)</tt> and <tt>record.items.proxy_association</tt> will return | |
# the same object, allowing you to make calls like <tt>proxy_association.owner</tt> inside | |
# association extensions. | |
# | |
# == Association Join Models | |
# | |
# Has Many associations can be configured with the <tt>:through</tt> option to use an | |
# explicit join model to retrieve the data. This operates similarly to a | |
# #has_and_belongs_to_many association. The advantage is that you're able to add validations, | |
# callbacks, and extra attributes on the join model. Consider the following schema: | |
# | |
# class Author < ActiveRecord::Base | |
# has_many :authorships | |
# has_many :books, through: :authorships | |
# end | |
# | |
# class Authorship < ActiveRecord::Base | |
# belongs_to :author | |
# belongs_to :book | |
# end | |
# | |
# @author = Author.first | |
# @author.authorships.collect { |a| a.book } # selects all books that the author's authorships belong to | |
# @author.books # selects all books by using the Authorship join model | |
# | |
# You can also go through a #has_many association on the join model: | |
# | |
# class Firm < ActiveRecord::Base | |
# has_many :clients | |
# has_many :invoices, through: :clients | |
# end | |
# | |
# class Client < ActiveRecord::Base | |
# belongs_to :firm | |
# has_many :invoices | |
# end | |
# | |
# class Invoice < ActiveRecord::Base | |
# belongs_to :client | |
# end | |
# | |
# @firm = Firm.first | |
# @firm.clients.flat_map { |c| c.invoices } # select all invoices for all clients of the firm | |
# @firm.invoices # selects all invoices by going through the Client join model | |
# | |
# Similarly you can go through a #has_one association on the join model: | |
# | |
# class Group < ActiveRecord::Base | |
# has_many :users | |
# has_many :avatars, through: :users | |
# end | |
# | |
# class User < ActiveRecord::Base | |
# belongs_to :group | |
# has_one :avatar | |
# end | |
# | |
# class Avatar < ActiveRecord::Base | |
# belongs_to :user | |
# end | |
# | |
# @group = Group.first | |
# @group.users.collect { |u| u.avatar }.compact # select all avatars for all users in the group | |
# @group.avatars # selects all avatars by going through the User join model. | |
# | |
# An important caveat with going through #has_one or #has_many associations on the | |
# join model is that these associations are *read-only*. For example, the following | |
# would not work following the previous example: | |
# | |
# @group.avatars << Avatar.new # this would work if User belonged_to Avatar rather than the other way around | |
# @group.avatars.delete(@group.avatars.last) # so would this | |
# | |
# == Setting Inverses | |
# | |
# If you are using a #belongs_to on the join model, it is a good idea to set the | |
# <tt>:inverse_of</tt> option on the #belongs_to, which will mean that the following example | |
# works correctly (where <tt>tags</tt> is a #has_many <tt>:through</tt> association): | |
# | |
# @post = Post.first | |
# @tag = @post.tags.build name: "ruby" | |
# @tag.save | |
# | |
# The last line ought to save the through record (a <tt>Tagging</tt>). This will only work if the | |
# <tt>:inverse_of</tt> is set: | |
# | |
# class Tagging < ActiveRecord::Base | |
# belongs_to :post | |
# belongs_to :tag, inverse_of: :taggings | |
# end | |
# | |
# If you do not set the <tt>:inverse_of</tt> record, the association will | |
# do its best to match itself up with the correct inverse. Automatic | |
# inverse detection only works on #has_many, #has_one, and | |
# #belongs_to associations. | |
# | |
# <tt>:foreign_key</tt> and <tt>:through</tt> options on the associations | |
# will also prevent the association's inverse from being found automatically, | |
# as will a custom scopes in some cases. See further details in the | |
# {Active Record Associations guide}[https://guides.rubyonrails.org/association_basics.html#bi-directional-associations]. | |
# | |
# 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, | |
# especially the ones with non-standard names. | |
# | |
# You can turn off the automatic detection of inverse associations by setting | |
# the <tt>:inverse_of</tt> option to <tt>false</tt> like so: | |
# | |
# class Tagging < ActiveRecord::Base | |
# belongs_to :tag, inverse_of: false | |
# end | |
# | |
# == Nested \Associations | |
# | |
# You can actually specify *any* association with the <tt>:through</tt> option, including an | |
# association which has a <tt>:through</tt> option itself. For example: | |
# | |
# class Author < ActiveRecord::Base | |
# has_many :posts | |
# has_many :comments, through: :posts | |
# has_many :commenters, through: :comments | |
# end | |
# | |
# class Post < ActiveRecord::Base | |
# has_many :comments | |
# end | |
# | |
# class Comment < ActiveRecord::Base | |
# belongs_to :commenter | |
# end | |
# | |
# @author = Author.first | |
# @author.commenters # => People who commented on posts written by the author | |
# | |
# An equivalent way of setting up this association this would be: | |
# | |
# class Author < ActiveRecord::Base | |
# has_many :posts | |
# has_many :commenters, through: :posts | |
# end | |
# | |
# class Post < ActiveRecord::Base | |
# has_many :comments | |
# has_many :commenters, through: :comments | |
# end | |
# | |
# class Comment < ActiveRecord::Base | |
# belongs_to :commenter | |
# end | |
# | |
# When using a nested association, you will not be able to modify the association because there | |
# is not enough information to know what modification to make. For example, if you tried to | |
# add a <tt>Commenter</tt> in the example above, there would be no way to tell how to set up the | |
# intermediate <tt>Post</tt> and <tt>Comment</tt> objects. | |
# | |
# == Polymorphic \Associations | |
# | |
# Polymorphic associations on models are not restricted on what types of models they | |
# can be associated with. Rather, they specify an interface that a #has_many association | |
# must adhere to. | |
# | |
# class Asset < ActiveRecord::Base | |
# belongs_to :attachable, polymorphic: true | |
# end | |
# | |
# class Post < ActiveRecord::Base | |
# has_many :assets, as: :attachable # The :as option specifies the polymorphic interface to use. | |
# end | |
# | |
# @asset.attachable = @post | |
# | |
# This works by using a type column in addition to a foreign key to specify the associated | |
# record. In the Asset example, you'd need an +attachable_id+ integer column and an | |
# +attachable_type+ string column. | |
# | |
# Using polymorphic associations in combination with single table inheritance (STI) is | |
# a little tricky. In order for the associations to work as expected, ensure that you | |
# store the base model for the STI models in the type column of the polymorphic | |
# association. To continue with the asset example above, suppose there are guest posts | |
# and member posts that use the posts table for STI. In this case, there must be a +type+ | |
# column in the posts table. | |
# | |
# Note: The <tt>attachable_type=</tt> method is being called when assigning an +attachable+. | |
# The +class_name+ of the +attachable+ is passed as a String. | |
# | |
# class Asset < ActiveRecord::Base | |
# belongs_to :attachable, polymorphic: true | |
# | |
# def attachable_type=(class_name) | |
# super(class_name.constantize.base_class.to_s) | |
# end | |
# end | |
# | |
# class Post < ActiveRecord::Base | |
# # because we store "Post" in attachable_type now dependent: :destroy will work | |
# has_many :assets, as: :attachable, dependent: :destroy | |
# end | |
# | |
# class GuestPost < Post | |
# end | |
# | |
# class MemberPost < Post | |
# end | |
# | |
# == Caching | |
# | |
# All of the methods are built on a simple caching principle that will keep the result | |
# of the last query around unless specifically instructed not to. The cache is even | |
# shared across methods to make it even cheaper to use the macro-added methods without | |
# worrying too much about performance at the first go. | |
# | |
# project.milestones # fetches milestones from the database | |
# project.milestones.size # uses the milestone cache | |
# project.milestones.empty? # uses the milestone cache | |
# project.milestones.reload.size # fetches milestones from the database | |
# project.milestones # uses the milestone cache | |
# | |
# == Eager loading of associations | |
# | |
# Eager loading is a way to find objects of a certain class and a number of named associations. | |
# It is one of the easiest ways to prevent the dreaded N+1 problem in which fetching 100 | |
# posts that each need to display their author triggers 101 database queries. Through the | |
# use of eager loading, the number of queries will be reduced from 101 to 2. | |
# | |
# class Post < ActiveRecord::Base | |
# belongs_to :author | |
# has_many :comments | |
# end | |
# | |
# Consider the following loop using the class above: | |
# | |
# Post.all.each do |post| | |
# puts "Post: " + post.title | |
# puts "Written by: " + post.author.name | |
# puts "Last comment on: " + post.comments.first.created_on | |
# end | |
# | |
# To iterate over these one hundred posts, we'll generate 201 database queries. Let's | |
# first just optimize it for retrieving the author: | |
# | |
# Post.includes(:author).each do |post| | |
# | |
# This references the name of the #belongs_to association that also used the <tt>:author</tt> | |
# symbol. After loading the posts, +find+ will collect the +author_id+ from each one and load | |
# all of the referenced authors with one query. Doing so will cut down the number of queries | |
# from 201 to 102. | |
# | |
# We can improve upon the situation further by referencing both associations in the finder with: | |
# | |
# Post.includes(:author, :comments).each do |post| | |
# | |
# This will load all comments with a single query. This reduces the total number of queries | |
# to 3. In general, the number of queries will be 1 plus the number of associations | |
# named (except if some of the associations are polymorphic #belongs_to - see below). | |
# | |
# To include a deep hierarchy of associations, use a hash: | |
# | |
# Post.includes(:author, { comments: { author: :gravatar } }).each do |post| | |
# | |
# The above code will load all the comments and all of their associated | |
# authors and gravatars. You can mix and match any combination of symbols, | |
# arrays, and hashes to retrieve the associations you want to load. | |
# | |
# All of this power shouldn't fool you into thinking that you can pull out huge amounts | |
# of data with no performance penalty just because you've reduced the number of queries. | |
# The database still needs to send all the data to Active Record and it still needs to | |
# be processed. So it's no catch-all for performance problems, but it's a great way to | |
# cut down on the number of queries in a situation as the one described above. | |
# | |
# Since only one table is loaded at a time, conditions or orders cannot reference tables | |
# other than the main one. If this is the case, Active Record falls back to the previously | |
# used <tt>LEFT OUTER JOIN</tt> based strategy. For example: | |
# | |
# Post.includes([:author, :comments]).where(['comments.approved = ?', true]) | |
# | |
# This will result in a single SQL query with joins along the lines of: | |
# <tt>LEFT OUTER JOIN comments ON comments.post_id = posts.id</tt> and | |
# <tt>LEFT OUTER JOIN authors ON authors.id = posts.author_id</tt>. Note that using conditions | |
# like this can have unintended consequences. | |
# In the above example, posts with no approved comments are not returned at all because | |
# the conditions apply to the SQL statement as a whole and not just to the association. | |
# | |
# You must disambiguate column references for this fallback to happen, for example | |
# <tt>order: "author.name DESC"</tt> will work but <tt>order: "name DESC"</tt> will not. | |
# | |
# If you want to load all posts (including posts with no approved comments), then write | |
# your own <tt>LEFT OUTER JOIN</tt> query using <tt>ON</tt>: | |
# | |
# Post.joins("LEFT OUTER JOIN comments ON comments.post_id = posts.id AND comments.approved = '1'") | |
# | |
# In this case, it is usually more natural to include an association which has conditions defined on it: | |
# | |
# class Post < ActiveRecord::Base | |
# has_many :approved_comments, -> { where(approved: true) }, class_name: 'Comment' | |
# end | |
# | |
# Post.includes(:approved_comments) | |
# | |
# This will load posts and eager load the +approved_comments+ association, which contains | |
# only those comments that have been approved. | |
# | |
# If you eager load an association with a specified <tt>:limit</tt> option, it will be ignored, | |
# returning all the associated objects: | |
# | |
# class Picture < ActiveRecord::Base | |
# has_many :most_recent_comments, -> { order('id DESC').limit(10) }, class_name: 'Comment' | |
# end | |
# | |
# Picture.includes(:most_recent_comments).first.most_recent_comments # => returns all associated comments. | |
# | |
# Eager loading is supported with polymorphic associations. | |
# | |
# class Address < ActiveRecord::Base | |
# belongs_to :addressable, polymorphic: true | |
# end | |
# | |
# A call that tries to eager load the addressable model | |
# | |
# Address.includes(:addressable) | |
# | |
# This will execute one query to load the addresses and load the addressables with one | |
# query per addressable type. | |
# For example, if all the addressables are either of class Person or Company, then a total | |
# of 3 queries will be executed. The list of addressable types to load is determined on | |
# the back of the addresses loaded. This is not supported if Active Record has to fallback | |
# to the previous implementation of eager loading and will raise ActiveRecord::EagerLoadPolymorphicError. | |
# The reason is that the parent model's type is a column value so its corresponding table | |
# name cannot be put in the +FROM+/+JOIN+ clauses of that query. | |
# | |
# == Table Aliasing | |
# | |
# Active Record uses table aliasing in the case that a table is referenced multiple times | |
# in a join. If a table is referenced only once, the standard table name is used. The | |
# second time, the table is aliased as <tt>#{reflection_name}_#{parent_table_name}</tt>. | |
# Indexes are appended for any more successive uses of the table name. | |
# | |
# Post.joins(:comments) | |
# # => SELECT ... FROM posts INNER JOIN comments ON ... | |
# Post.joins(:special_comments) # STI | |
# # => SELECT ... FROM posts INNER JOIN comments ON ... AND comments.type = 'SpecialComment' | |
# Post.joins(:comments, :special_comments) # special_comments is the reflection name, posts is the parent table name | |
# # => SELECT ... FROM posts INNER JOIN comments ON ... INNER JOIN comments special_comments_posts | |
# | |
# Acts as tree example: | |
# | |
# TreeMixin.joins(:children) | |
# # => SELECT ... FROM mixins INNER JOIN mixins childrens_mixins ... | |
# TreeMixin.joins(children: :parent) | |
# # => SELECT ... FROM mixins INNER JOIN mixins childrens_mixins ... | |
# INNER JOIN parents_mixins ... | |
# TreeMixin.joins(children: {parent: :children}) | |
# # => SELECT ... FROM mixins INNER JOIN mixins childrens_mixins ... | |
# INNER JOIN parents_mixins ... | |
# INNER JOIN mixins childrens_mixins_2 | |
# | |
# Has and Belongs to Many join tables use the same idea, but add a <tt>_join</tt> suffix: | |
# | |
# Post.joins(:categories) | |
# # => SELECT ... FROM posts INNER JOIN categories_posts ... INNER JOIN categories ... | |
# Post.joins(categories: :posts) | |
# # => SELECT ... FROM posts INNER JOIN categories_posts ... INNER JOIN categories ... | |
# INNER JOIN categories_posts posts_categories_join INNER JOIN posts posts_categories | |
# Post.joins(categories: {posts: :categories}) | |
# # => SELECT ... FROM posts INNER JOIN categories_posts ... INNER JOIN categories ... | |
# INNER JOIN categories_posts posts_categories_join INNER JOIN posts posts_categories | |
# INNER JOIN categories_posts categories_posts_join INNER JOIN categories categories_posts_2 | |
# | |
# If you wish to specify your own custom joins using ActiveRecord::QueryMethods#joins method, those table | |
# names will take precedence over the eager associations: | |
# | |
# Post.joins(:comments).joins("inner join comments ...") | |
# # => SELECT ... FROM posts INNER JOIN comments_posts ON ... INNER JOIN comments ... | |
# Post.joins(:comments, :special_comments).joins("inner join comments ...") | |
# # => SELECT ... FROM posts INNER JOIN comments comments_posts ON ... | |
# INNER JOIN comments special_comments_posts ... | |
# INNER JOIN comments ... | |
# | |
# Table aliases are automatically truncated according to the maximum length of table identifiers | |
# according to the specific database. | |
# | |
# == Modules | |
# | |
# By default, associations will look for objects within the current module scope. Consider: | |
# | |
# module MyApplication | |
# module Business | |
# class Firm < ActiveRecord::Base | |
# has_many :clients | |
# end | |
# | |
# class Client < ActiveRecord::Base; end | |
# end | |
# end | |
# | |
# When <tt>Firm#clients</tt> is called, it will in turn call | |
# <tt>MyApplication::Business::Client.find_all_by_firm_id(firm.id)</tt>. | |
# If you want to associate with a class in another module scope, this can be done by | |
# specifying the complete class name. | |
# | |
# module MyApplication | |
# module Business | |
# class Firm < ActiveRecord::Base; end | |
# end | |
# | |
# module Billing | |
# class Account < ActiveRecord::Base | |
# belongs_to :firm, class_name: "MyApplication::Business::Firm" | |
# end | |
# end | |
# end | |
# | |
# == Bi-directional associations | |
# | |
# When you specify an association, there is usually an association on the associated model | |
# that specifies the same relationship in reverse. For example, with the following models: | |
# | |
# class Dungeon < ActiveRecord::Base | |
# has_many :traps | |
# has_one :evil_wizard | |
# end | |
# | |
# class Trap < ActiveRecord::Base | |
# belongs_to :dungeon | |
# end | |
# | |
# class EvilWizard < ActiveRecord::Base | |
# belongs_to :dungeon | |
# end | |
# | |
# The +traps+ association on +Dungeon+ and the +dungeon+ association on +Trap+ are | |
# the inverse of each other, and the inverse of the +dungeon+ association on +EvilWizard+ | |
# is the +evil_wizard+ association on +Dungeon+ (and vice-versa). By default, | |
# Active Record can guess the inverse of the association based on the name | |
# of the class. The result is the following: | |
# | |
# d = Dungeon.first | |
# t = d.traps.first | |
# d.object_id == t.dungeon.object_id # => true | |
# | |
# The +Dungeon+ instances +d+ and <tt>t.dungeon</tt> in the above example refer to | |
# the same in-memory instance since the association matches the name of the class. | |
# The result would be the same if we added +:inverse_of+ to our model definitions: | |
# | |
# class Dungeon < ActiveRecord::Base | |
# has_many :traps, inverse_of: :dungeon | |
# has_one :evil_wizard, inverse_of: :dungeon | |
# end | |
# | |
# class Trap < ActiveRecord::Base | |
# belongs_to :dungeon, inverse_of: :traps | |
# end | |
# | |
# class EvilWizard < ActiveRecord::Base | |
# belongs_to :dungeon, inverse_of: :evil_wizard | |
# end | |
# | |
# For more information, see the documentation for the +:inverse_of+ option and the | |
# {Active Record Associations guide}[https://guides.rubyonrails.org/association_basics.html#bi-directional-associations]. | |
# | |
# == Deleting from associations | |
# | |
# === Dependent associations | |
# | |
# #has_many, #has_one, and #belongs_to associations support the <tt>:dependent</tt> option. | |
# This allows you to specify that associated records should be deleted when the owner is | |
# deleted. | |
# | |
# For example: | |
# | |
# class Author | |
# has_many :posts, dependent: :destroy | |
# end | |
# Author.find(1).destroy # => Will destroy all of the author's posts, too | |
# | |
# The <tt>:dependent</tt> option can have different values which specify how the deletion | |
# is done. For more information, see the documentation for this option on the different | |
# specific association types. When no option is given, the behavior is to do nothing | |
# with the associated records when destroying a record. | |
# | |
# Note that <tt>:dependent</tt> is implemented using Rails' callback | |
# system, which works by processing callbacks in order. Therefore, other | |
# callbacks declared either before or after the <tt>:dependent</tt> option | |
# can affect what it does. | |
# | |
# Note that <tt>:dependent</tt> option is ignored for #has_one <tt>:through</tt> associations. | |
# | |
# === Delete or destroy? | |
# | |
# #has_many and #has_and_belongs_to_many associations have the methods <tt>destroy</tt>, | |
# <tt>delete</tt>, <tt>destroy_all</tt> and <tt>delete_all</tt>. | |
# | |
# For #has_and_belongs_to_many, <tt>delete</tt> and <tt>destroy</tt> are the same: they | |
# cause the records in the join table to be removed. | |
# | |
# For #has_many, <tt>destroy</tt> and <tt>destroy_all</tt> will always call the <tt>destroy</tt> method of the | |
# record(s) being removed so that callbacks are run. However <tt>delete</tt> and <tt>delete_all</tt> will either | |
# do the deletion according to the strategy specified by the <tt>:dependent</tt> option, or | |
# if no <tt>:dependent</tt> option is given, then it will follow the default strategy. | |
# The default strategy is to do nothing (leave the foreign keys with the parent ids set), except for | |
# #has_many <tt>:through</tt>, where the default strategy is <tt>delete_all</tt> (delete | |
# the join records, without running their callbacks). | |
# | |
# There is also a <tt>clear</tt> method which is the same as <tt>delete_all</tt>, except that | |
# it returns the association rather than the records which have been deleted. | |
# | |
# === What gets deleted? | |
# | |
# There is a potential pitfall here: #has_and_belongs_to_many and #has_many <tt>:through</tt> | |
# associations have records in join tables, as well as the associated records. So when we | |
# call one of these deletion methods, what exactly should be deleted? | |
# | |
# The answer is that it is assumed that deletion on an association is about removing the | |
# <i>link</i> between the owner and the associated object(s), rather than necessarily the | |
# associated objects themselves. So with #has_and_belongs_to_many and #has_many | |
# <tt>:through</tt>, the join records will be deleted, but the associated records won't. | |
# | |
# This makes sense if you think about it: if you were to call <tt>post.tags.delete(Tag.find_by(name: 'food'))</tt> | |
# you would want the 'food' tag to be unlinked from the post, rather than for the tag itself | |
# to be removed from the database. | |
# | |
# However, there are examples where this strategy doesn't make sense. For example, suppose | |
# a person has many projects, and each project has many tasks. If we deleted one of a person's | |
# tasks, we would probably not want the project to be deleted. In this scenario, the delete method | |
# won't actually work: it can only be used if the association on the join model is a | |
# #belongs_to. In other situations you are expected to perform operations directly on | |
# either the associated records or the <tt>:through</tt> association. | |
# | |
# With a regular #has_many there is no distinction between the "associated records" | |
# and the "link", so there is only one choice for what gets deleted. | |
# | |
# With #has_and_belongs_to_many and #has_many <tt>:through</tt>, if you want to delete the | |
# associated records themselves, you can always do something along the lines of | |
# <tt>person.tasks.each(&:destroy)</tt>. | |
# | |
# == Type safety with ActiveRecord::AssociationTypeMismatch | |
# | |
# If you attempt to assign an object to an association that doesn't match the inferred | |
# or specified <tt>:class_name</tt>, you'll get an ActiveRecord::AssociationTypeMismatch. | |
# | |
# == Options | |
# | |
# All of the association macros can be specialized through options. This makes cases | |
# more complex than the simple and guessable ones possible. | |
module ClassMethods | |
# Specifies a one-to-many association. The following methods for retrieval and query of | |
# collections of associated objects will be added: | |
# | |
# +collection+ is a placeholder for the symbol passed as the +name+ argument, so | |
# <tt>has_many :clients</tt> would add among others <tt>clients.empty?</tt>. | |
# | |
# [collection] | |
# Returns a Relation of all the associated objects. | |
# An empty Relation is returned if none are found. | |
# [collection<<(object, ...)] | |
# Adds one or more objects to the collection by setting their foreign keys to the collection's primary key. | |
# Note that this operation instantly fires update SQL without waiting for the save or update call on the | |
# parent object, unless the parent object is a new record. | |
# This will also run validations and callbacks of associated object(s). | |
# [collection.delete(object, ...)] | |
# Removes one or more objects from the collection by setting their foreign keys to +NULL+. | |
# Objects will be in addition destroyed if they're associated with <tt>dependent: :destroy</tt>, | |
# and deleted if they're associated with <tt>dependent: :delete_all</tt>. | |
# | |
# If the <tt>:through</tt> option is used, then the join records are deleted (rather than | |
# nullified) by default, but you can specify <tt>dependent: :destroy</tt> or | |
# <tt>dependent: :nullify</tt> to override this. | |
# [collection.destroy(object, ...)] | |
# Removes one or more objects from the collection by running <tt>destroy</tt> on | |
# each record, regardless of any dependent option, ensuring callbacks are run. | |
# | |
# If the <tt>:through</tt> option is used, then the join records are destroyed | |
# instead, not the objects themselves. | |
# [collection=objects] | |
# Replaces the collections content by deleting and adding objects as appropriate. If the <tt>:through</tt> | |
# option is true callbacks in the join models are triggered except destroy callbacks, since deletion is | |
# direct by default. You can specify <tt>dependent: :destroy</tt> or | |
# <tt>dependent: :nullify</tt> to override this. | |
# [collection_singular_ids] | |
# Returns an array of the associated objects' ids | |
# [collection_singular_ids=ids] | |
# Replace the collection with the objects identified by the primary keys in +ids+. This | |
# method loads the models and calls <tt>collection=</tt>. See above. | |
# [collection.clear] | |
# Removes every object from the collection. This destroys the associated objects if they | |
# are associated with <tt>dependent: :destroy</tt>, deletes them directly from the | |
# database if <tt>dependent: :delete_all</tt>, otherwise sets their foreign keys to +NULL+. | |
# If the <tt>:through</tt> option is true no destroy callbacks are invoked on the join models. | |
# Join models are directly deleted. | |
# [collection.empty?] | |
# Returns +true+ if there are no associated objects. | |
# [collection.size] | |
# Returns the number of associated objects. | |
# [collection.find(...)] | |
# Finds an associated object according to the same rules as ActiveRecord::FinderMethods#find. | |
# [collection.exists?(...)] | |
# Checks whether an associated object with the given conditions exists. | |
# Uses the same rules as ActiveRecord::FinderMethods#exists?. | |
# [collection.build(attributes = {}, ...)] | |
# Returns one or more new objects of the collection type that have been instantiated | |
# with +attributes+ and linked to this object through a foreign key, but have not yet | |
# been saved. | |
# [collection.create(attributes = {})] | |
# Returns a new object of the collection type that has been instantiated | |
# with +attributes+, linked to this object through a foreign key, and that has already | |
# been saved (if it passed the validation). *Note*: This only works if the base model | |
# already exists in the DB, not if it is a new (unsaved) record! | |
# [collection.create!(attributes = {})] | |
# Does the same as <tt>collection.create</tt>, but raises ActiveRecord::RecordInvalid | |
# if the record is invalid. | |
# [collection.reload] | |
# Returns a Relation of all of the associated objects, forcing a database read. | |
# An empty Relation is returned if none are found. | |
# | |
# === Example | |
# | |
# A <tt>Firm</tt> class declares <tt>has_many :clients</tt>, which will add: | |
# * <tt>Firm#clients</tt> (similar to <tt>Client.where(firm_id: id)</tt>) | |
# * <tt>Firm#clients<<</tt> | |
# * <tt>Firm#clients.delete</tt> | |
# * <tt>Firm#clients.destroy</tt> | |
# * <tt>Firm#clients=</tt> | |
# * <tt>Firm#client_ids</tt> | |
# * <tt>Firm#client_ids=</tt> | |
# * <tt>Firm#clients.clear</tt> | |
# * <tt>Firm#clients.empty?</tt> (similar to <tt>firm.clients.size == 0</tt>) | |
# * <tt>Firm#clients.size</tt> (similar to <tt>Client.count "firm_id = #{id}"</tt>) | |
# * <tt>Firm#clients.find</tt> (similar to <tt>Client.where(firm_id: id).find(id)</tt>) | |
# * <tt>Firm#clients.exists?(name: 'ACME')</tt> (similar to <tt>Client.exists?(name: 'ACME', firm_id: firm.id)</tt>) | |
# * <tt>Firm#clients.build</tt> (similar to <tt>Client.new(firm_id: id)</tt>) | |
# * <tt>Firm#clients.create</tt> (similar to <tt>c = Client.new(firm_id: id); c.save; c</tt>) | |
# * <tt>Firm#clients.create!</tt> (similar to <tt>c = Client.new(firm_id: id); c.save!</tt>) | |
# * <tt>Firm#clients.reload</tt> | |
# The declaration can also include an +options+ hash to specialize the behavior of the association. | |
# | |
# === Scopes | |
# | |
# You can pass a second argument +scope+ as a callable (i.e. proc or | |
# lambda) to retrieve a specific set of records or customize the generated | |
# query when you access the associated collection. | |
# | |
# Scope examples: | |
# has_many :comments, -> { where(author_id: 1) } | |
# has_many :employees, -> { joins(:address) } | |
# has_many :posts, ->(blog) { where("max_post_length > ?", blog.max_post_length) } | |
# | |
# === Extensions | |
# | |
# The +extension+ argument allows you to pass a block into a has_many | |
# association. This is useful for adding new finders, creators, and other | |
# factory-type methods to be used as part of the association. | |
# | |
# Extension examples: | |
# has_many :employees do | |
# def find_or_create_by_name(name) | |
# first_name, last_name = name.split(" ", 2) | |
# find_or_create_by(first_name: first_name, last_name: last_name) | |
# end | |
# end | |
# | |
# === Options | |
# [:class_name] | |
# Specify the class name of the association. Use it only if that name can't be inferred | |
# from the association name. So <tt>has_many :products</tt> will by default be linked | |
# to the +Product+ class, but if the real class name is +SpecialProduct+, you'll have to | |
# specify it with this option. | |
# [:foreign_key] | |
# Specify the foreign key used for the association. By default this is guessed to be the name | |
# of this class in lower-case and "_id" suffixed. So a Person class that makes a #has_many | |
# association will use "person_id" as the default <tt>:foreign_key</tt>. | |
# | |
# Setting the <tt>:foreign_key</tt> option prevents automatic detection of the association's | |
# inverse, so it is generally a good idea to set the <tt>:inverse_of</tt> option as well. | |
# [:foreign_type] | |
# Specify the column used to store the associated object's type, if this is a polymorphic | |
# association. By default this is guessed to be the name of the polymorphic association | |
# specified on "as" option with a "_type" suffix. So a class that defines a | |
# <tt>has_many :tags, as: :taggable</tt> association will use "taggable_type" as the | |
# default <tt>:foreign_type</tt>. | |
# [:primary_key] | |
# Specify the name of the column to use as the primary key for the association. By default this is +id+. | |
# [:dependent] | |
# Controls what happens to the associated objects when | |
# their owner is destroyed. Note that these are implemented as | |
# callbacks, and Rails executes callbacks in order. Therefore, other | |
# similar callbacks may affect the <tt>:dependent</tt> behavior, and the | |
# <tt>:dependent</tt> behavior may affect other callbacks. | |
# | |
# * <tt>nil</tt> do nothing (default). | |
# * <tt>:destroy</tt> causes all the associated objects to also be destroyed. | |
# * <tt>:destroy_async</tt> destroys all the associated objects in a background job. <b>WARNING:</b> Do not use | |
# this option if the association is backed by foreign key constraints in your database. The foreign key | |
# constraint actions will occur inside the same transaction that deletes its owner. | |
# * <tt>:delete_all</tt> causes all the associated objects to be deleted directly from the database (so callbacks will not be executed). | |
# * <tt>:nullify</tt> causes the foreign keys to be set to +NULL+. Polymorphic type will also be nullified | |
# on polymorphic associations. Callbacks are not executed. | |
# * <tt>:restrict_with_exception</tt> causes an <tt>ActiveRecord::DeleteRestrictionError</tt> exception to be raised if there are any associated records. | |
# * <tt>:restrict_with_error</tt> causes an error to be added to the owner if there are any associated objects. | |
# | |
# If using with the <tt>:through</tt> option, the association on the join model must be | |
# a #belongs_to, and the records which get deleted are the join records, rather than | |
# the associated records. | |
# | |
# If using <tt>dependent: :destroy</tt> on a scoped association, only the scoped objects are destroyed. | |
# For example, if a Post model defines | |
# <tt>has_many :comments, -> { where published: true }, dependent: :destroy</tt> and <tt>destroy</tt> is | |
# called on a post, only published comments are destroyed. This means that any unpublished comments in the | |
# database would still contain a foreign key pointing to the now deleted post. | |
# [:counter_cache] | |
# This option can be used to configure a custom named <tt>:counter_cache.</tt> You only need this option, | |
# when you customized the name of your <tt>:counter_cache</tt> on the #belongs_to association. | |
# [:as] | |
# Specifies a polymorphic interface (See #belongs_to). | |
# [:through] | |
# Specifies an association through which to perform the query. This can be any other type | |
# of association, including other <tt>:through</tt> associations. Options for <tt>:class_name</tt>, | |
# <tt>:primary_key</tt> and <tt>:foreign_key</tt> are ignored, as the association uses the | |
# source reflection. | |
# | |
# If the association on the join model is a #belongs_to, the collection can be modified | |
# and the records on the <tt>:through</tt> model will be automatically created and removed | |
# as appropriate. Otherwise, the collection is read-only, so you should manipulate the | |
# <tt>:through</tt> association directly. | |
# | |
# If you are going to modify the association (rather than just read from it), then it is | |
# a good idea to set the <tt>:inverse_of</tt> option on the source association on the | |
# join model. This allows associated records to be built which will automatically create | |
# the appropriate join model records when they are saved. (See the 'Association Join Models' | |
# and 'Setting Inverses' sections above.) | |
# [:disable_joins] | |
# Specifies whether joins should be skipped for an association. If set to true, two or more queries | |
# will be generated. Note that in some cases, if order or limit is applied, it will be done in-memory | |
# due to database limitations. This option is only applicable on <tt>has_many :through</tt> associations as | |
# +has_many+ alone do not perform a join. | |
# [:source] | |
# Specifies the source association name used by #has_many <tt>:through</tt> queries. | |
# Only use it if the name cannot be inferred from the association. | |
# <tt>has_many :subscribers, through: :subscriptions</tt> will look for either <tt>:subscribers</tt> or | |
# <tt>:subscriber</tt> on Subscription, unless a <tt>:source</tt> is given. | |
# [:source_type] | |
# Specifies type of the source association used by #has_many <tt>:through</tt> queries where the source | |
# association is a polymorphic #belongs_to. | |
# [:validate] | |
# When set to +true+, validates new objects added to association when saving the parent object. +true+ by default. | |
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+. | |
# [:autosave] | |
# If true, always save the associated objects or destroy them if marked for destruction, | |
# when saving the parent object. If false, never save or destroy the associated objects. | |
# By default, only save associated objects that are new records. This option is implemented as a | |
# +before_save+ callback. Because callbacks are run in the order they are defined, associated objects | |
# may need to be explicitly saved in any user-defined +before_save+ callbacks. | |
# | |
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets | |
# <tt>:autosave</tt> to <tt>true</tt>. | |
# [:inverse_of] | |
# Specifies the name of the #belongs_to association on the associated object | |
# that is the inverse of this #has_many association. | |
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail. | |
# [:extend] | |
# Specifies a module or array of modules that will be extended into the association object returned. | |
# Useful for defining methods on associations, especially when they should be shared between multiple | |
# association objects. | |
# [:strict_loading] | |
# When set to +true+, enforces strict loading every time the associated record is loaded through this | |
# association. | |
# [:ensuring_owner_was] | |
# Specifies an instance method to be called on the owner. The method must return true in order for the | |
# associated records to be deleted in a background job. | |
# | |
# Option examples: | |
# has_many :comments, -> { order("posted_on") } | |
# has_many :comments, -> { includes(:author) } | |
# has_many :people, -> { where(deleted: false).order("name") }, class_name: "Person" | |
# has_many :tracks, -> { order("position") }, dependent: :destroy | |
# has_many :comments, dependent: :nullify | |
# has_many :tags, as: :taggable | |
# has_many :reports, -> { readonly } | |
# has_many :subscribers, through: :subscriptions, source: :user | |
# has_many :subscribers, through: :subscriptions, disable_joins: true | |
# has_many :comments, strict_loading: true | |
def has_many(name, scope = nil, **options, &extension) | |
reflection = Builder::HasMany.build(self, name, scope, options, &extension) | |
Reflection.add_reflection self, name, reflection | |
end | |
# Specifies a one-to-one association with another class. This method should only be used | |
# if the other class contains the foreign key. If the current class contains the foreign key, | |
# then you should use #belongs_to instead. See also ActiveRecord::Associations::ClassMethods's overview | |
# on when to use #has_one and when to use #belongs_to. | |
# | |
# The following methods for retrieval and query of a single associated object will be added: | |
# | |
# +association+ is a placeholder for the symbol passed as the +name+ argument, so | |
# <tt>has_one :manager</tt> would add among others <tt>manager.nil?</tt>. | |
# | |
# [association] | |
# Returns the associated object. +nil+ is returned if none is found. | |
# [association=(associate)] | |
# Assigns the associate object, extracts the primary key, sets it as the foreign key, | |
# and saves the associate object. To avoid database inconsistencies, permanently deletes an existing | |
# associated object when assigning a new one, even if the new one isn't saved to database. | |
# [build_association(attributes = {})] | |
# Returns a new object of the associated type that has been instantiated | |
# with +attributes+ and linked to this object through a foreign key, but has not | |
# yet been saved. | |
# [create_association(attributes = {})] | |
# Returns a new object of the associated type that has been instantiated | |
# with +attributes+, linked to this object through a foreign key, and that | |
# has already been saved (if it passed the validation). | |
# [create_association!(attributes = {})] | |
# Does the same as <tt>create_association</tt>, but raises ActiveRecord::RecordInvalid | |
# if the record is invalid. | |
# [reload_association] | |
# Returns the associated object, forcing a database read. | |
# | |
# === Example | |
# | |
# An Account class declares <tt>has_one :beneficiary</tt>, which will add: | |
# * <tt>Account#beneficiary</tt> (similar to <tt>Beneficiary.where(account_id: id).first</tt>) | |
# * <tt>Account#beneficiary=(beneficiary)</tt> (similar to <tt>beneficiary.account_id = account.id; beneficiary.save</tt>) | |
# * <tt>Account#build_beneficiary</tt> (similar to <tt>Beneficiary.new(account_id: id)</tt>) | |
# * <tt>Account#create_beneficiary</tt> (similar to <tt>b = Beneficiary.new(account_id: id); b.save; b</tt>) | |
# * <tt>Account#create_beneficiary!</tt> (similar to <tt>b = Beneficiary.new(account_id: id); b.save!; b</tt>) | |
# * <tt>Account#reload_beneficiary</tt> | |
# | |
# === Scopes | |
# | |
# You can pass a second argument +scope+ as a callable (i.e. proc or | |
# lambda) to retrieve a specific record or customize the generated query | |
# when you access the associated object. | |
# | |
# Scope examples: | |
# has_one :author, -> { where(comment_id: 1) } | |
# has_one :employer, -> { joins(:company) } | |
# has_one :latest_post, ->(blog) { where("created_at > ?", blog.enabled_at) } | |
# | |
# === Options | |
# | |
# The declaration can also include an +options+ hash to specialize the behavior of the association. | |
# | |
# Options are: | |
# [:class_name] | |
# Specify the class name of the association. Use it only if that name can't be inferred | |
# from the association name. So <tt>has_one :manager</tt> will by default be linked to the Manager class, but | |
# if the real class name is Person, you'll have to specify it with this option. | |
# [:dependent] | |
# Controls what happens to the associated object when | |
# its owner is destroyed: | |
# | |
# * <tt>nil</tt> do nothing (default). | |
# * <tt>:destroy</tt> causes the associated object to also be destroyed | |
# * <tt>:destroy_async</tt> causes the associated object to be destroyed in a background job. <b>WARNING:</b> Do not use | |
# this option if the association is backed by foreign key constraints in your database. The foreign key | |
# constraint actions will occur inside the same transaction that deletes its owner. | |
# * <tt>:delete</tt> causes the associated object to be deleted directly from the database (so callbacks will not execute) | |
# * <tt>:nullify</tt> causes the foreign key to be set to +NULL+. Polymorphic type column is also nullified | |
# on polymorphic associations. Callbacks are not executed. | |
# * <tt>:restrict_with_exception</tt> causes an <tt>ActiveRecord::DeleteRestrictionError</tt> exception to be raised if there is an associated record | |
# * <tt>:restrict_with_error</tt> causes an error to be added to the owner if there is an associated object | |
# | |
# Note that <tt>:dependent</tt> option is ignored when using <tt>:through</tt> option. | |
# [:foreign_key] | |
# Specify the foreign key used for the association. By default this is guessed to be the name | |
# of this class in lower-case and "_id" suffixed. So a Person class that makes a #has_one association | |
# will use "person_id" as the default <tt>:foreign_key</tt>. | |
# | |
# Setting the <tt>:foreign_key</tt> option prevents automatic detection of the association's | |
# inverse, so it is generally a good idea to set the <tt>:inverse_of</tt> option as well. | |
# [:foreign_type] | |
# Specify the column used to store the associated object's type, if this is a polymorphic | |
# association. By default this is guessed to be the name of the polymorphic association | |
# specified on "as" option with a "_type" suffix. So a class that defines a | |
# <tt>has_one :tag, as: :taggable</tt> association will use "taggable_type" as the | |
# default <tt>:foreign_type</tt>. | |
# [:primary_key] | |
# Specify the method that returns the primary key used for the association. By default this is +id+. | |
# [:as] | |
# Specifies a polymorphic interface (See #belongs_to). | |
# [:through] | |
# Specifies a Join Model through which to perform the query. Options for <tt>:class_name</tt>, | |
# <tt>:primary_key</tt>, and <tt>:foreign_key</tt> are ignored, as the association uses the | |
# source reflection. You can only use a <tt>:through</tt> query through a #has_one | |
# or #belongs_to association on the join model. | |
# | |
# If the association on the join model is a #belongs_to, the collection can be modified | |
# and the records on the <tt>:through</tt> model will be automatically created and removed | |
# as appropriate. Otherwise, the collection is read-only, so you should manipulate the | |
# <tt>:through</tt> association directly. | |
# | |
# If you are going to modify the association (rather than just read from it), then it is | |
# a good idea to set the <tt>:inverse_of</tt> option on the source association on the | |
# join model. This allows associated records to be built which will automatically create | |
# the appropriate join model records when they are saved. (See the 'Association Join Models' | |
# and 'Setting Inverses' sections above.) | |
# [:disable_joins] | |
# Specifies whether joins should be skipped for an association. If set to true, two or more queries | |
# will be generated. Note that in some cases, if order or limit is applied, it will be done in-memory | |
# due to database limitations. This option is only applicable on <tt>has_one :through</tt> associations as | |
# +has_one+ alone does not perform a join. | |
# [:source] | |
# Specifies the source association name used by #has_one <tt>:through</tt> queries. | |
# Only use it if the name cannot be inferred from the association. | |
# <tt>has_one :favorite, through: :favorites</tt> will look for a | |
# <tt>:favorite</tt> on Favorite, unless a <tt>:source</tt> is given. | |
# [:source_type] | |
# Specifies type of the source association used by #has_one <tt>:through</tt> queries where the source | |
# association is a polymorphic #belongs_to. | |
# [:validate] | |
# When set to +true+, validates new objects added to association when saving the parent object. +false+ by default. | |
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+. | |
# [:autosave] | |
# If true, always save the associated object or destroy it if marked for destruction, | |
# when saving the parent object. If false, never save or destroy the associated object. | |
# By default, only save the associated object if it's a new record. | |
# | |
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets | |
# <tt>:autosave</tt> to <tt>true</tt>. | |
# [:inverse_of] | |
# Specifies the name of the #belongs_to association on the associated object | |
# that is the inverse of this #has_one association. | |
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail. | |
# [:required] | |
# When set to +true+, the association will also have its presence validated. | |
# This will validate the association itself, not the id. You can use | |
# +:inverse_of+ to avoid an extra query during validation. | |
# [:strict_loading] | |
# Enforces strict loading every time the associated record is loaded through this association. | |
# [:ensuring_owner_was] | |
# Specifies an instance method to be called on the owner. The method must return true in order for the | |
# associated records to be deleted in a background job. | |
# | |
# Option examples: | |
# has_one :credit_card, dependent: :destroy # destroys the associated credit card | |
# has_one :credit_card, dependent: :nullify # updates the associated records foreign | |
# # key value to NULL rather than destroying it | |
# has_one :last_comment, -> { order('posted_on') }, class_name: "Comment" | |
# has_one :project_manager, -> { where(role: 'project_manager') }, class_name: "Person" | |
# has_one :attachment, as: :attachable | |
# has_one :boss, -> { readonly } | |
# has_one :club, through: :membership | |
# has_one :club, through: :membership, disable_joins: true | |
# has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable | |
# has_one :credit_card, required: true | |
# has_one :credit_card, strict_loading: true | |
def has_one(name, scope = nil, **options) | |
reflection = Builder::HasOne.build(self, name, scope, options) | |
Reflection.add_reflection self, name, reflection | |
end | |
# Specifies a one-to-one association with another class. This method should only be used | |
# if this class contains the foreign key. If the other class contains the foreign key, | |
# then you should use #has_one instead. See also ActiveRecord::Associations::ClassMethods's overview | |
# on when to use #has_one and when to use #belongs_to. | |
# | |
# Methods will be added for retrieval and query for a single associated object, for which | |
# this object holds an id: | |
# | |
# +association+ is a placeholder for the symbol passed as the +name+ argument, so | |
# <tt>belongs_to :author</tt> would add among others <tt>author.nil?</tt>. | |
# | |
# [association] | |
# Returns the associated object. +nil+ is returned if none is found. | |
# [association=(associate)] | |
# Assigns the associate object, extracts the primary key, and sets it as the foreign key. | |
# No modification or deletion of existing records takes place. | |
# [build_association(attributes = {})] | |
# Returns a new object of the associated type that has been instantiated | |
# with +attributes+ and linked to this object through a foreign key, but has not yet been saved. | |
# [create_association(attributes = {})] | |
# Returns a new object of the associated type that has been instantiated | |
# with +attributes+, linked to this object through a foreign key, and that | |
# has already been saved (if it passed the validation). | |
# [create_association!(attributes = {})] | |
# Does the same as <tt>create_association</tt>, but raises ActiveRecord::RecordInvalid | |
# if the record is invalid. | |
# [reload_association] | |
# Returns the associated object, forcing a database read. | |
# [association_changed?] | |
# Returns true if a new associate object has been assigned and the next save will update the foreign key. | |
# [association_previously_changed?] | |
# Returns true if the previous save updated the association to reference a new associate object. | |
# | |
# === Example | |
# | |
# A Post class declares <tt>belongs_to :author</tt>, which will add: | |
# * <tt>Post#author</tt> (similar to <tt>Author.find(author_id)</tt>) | |
# * <tt>Post#author=(author)</tt> (similar to <tt>post.author_id = author.id</tt>) | |
# * <tt>Post#build_author</tt> (similar to <tt>post.author = Author.new</tt>) | |
# * <tt>Post#create_author</tt> (similar to <tt>post.author = Author.new; post.author.save; post.author</tt>) | |
# * <tt>Post#create_author!</tt> (similar to <tt>post.author = Author.new; post.author.save!; post.author</tt>) | |
# * <tt>Post#reload_author</tt> | |
# * <tt>Post#author_changed?</tt> | |
# * <tt>Post#author_previously_changed?</tt> | |
# The declaration can also include an +options+ hash to specialize the behavior of the association. | |
# | |
# === Scopes | |
# | |
# You can pass a second argument +scope+ as a callable (i.e. proc or | |
# lambda) to retrieve a specific record or customize the generated query | |
# when you access the associated object. | |
# | |
# Scope examples: | |
# belongs_to :firm, -> { where(id: 2) } | |
# belongs_to :user, -> { joins(:friends) } | |
# belongs_to :level, ->(game) { where("game_level > ?", game.current_level) } | |
# | |
# === Options | |
# | |
# [:class_name] | |
# Specify the class name of the association. Use it only if that name can't be inferred | |
# from the association name. So <tt>belongs_to :author</tt> will by default be linked to the Author class, but | |
# if the real class name is Person, you'll have to specify it with this option. | |
# [:foreign_key] | |
# Specify the foreign key used for the association. By default this is guessed to be the name | |
# of the association with an "_id" suffix. So a class that defines a <tt>belongs_to :person</tt> | |
# association will use "person_id" as the default <tt>:foreign_key</tt>. Similarly, | |
# <tt>belongs_to :favorite_person, class_name: "Person"</tt> will use a foreign key | |
# of "favorite_person_id". | |
# | |
# Setting the <tt>:foreign_key</tt> option prevents automatic detection of the association's | |
# inverse, so it is generally a good idea to set the <tt>:inverse_of</tt> option as well. | |
# [:foreign_type] | |
# Specify the column used to store the associated object's type, if this is a polymorphic | |
# association. By default this is guessed to be the name of the association with a "_type" | |
# suffix. So a class that defines a <tt>belongs_to :taggable, polymorphic: true</tt> | |
# association will use "taggable_type" as the default <tt>:foreign_type</tt>. | |
# [:primary_key] | |
# Specify the method that returns the primary key of associated object used for the association. | |
# By default this is +id+. | |
# [:dependent] | |
# If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to | |
# <tt>:delete</tt>, the associated object is deleted *without* calling its destroy method. If set to | |
# <tt>:destroy_async</tt>, the associated object is scheduled to be destroyed in a background job. | |
# This option should not be specified when #belongs_to is used in conjunction with | |
# a #has_many relationship on another class because of the potential to leave | |
# orphaned records behind. | |
# [:counter_cache] | |
# Caches the number of belonging objects on the associate class through the use of CounterCache::ClassMethods#increment_counter | |
# and CounterCache::ClassMethods#decrement_counter. The counter cache is incremented when an object of this | |
# class is created and decremented when it's destroyed. This requires that a column | |
# named <tt>#{table_name}_count</tt> (such as +comments_count+ for a belonging Comment class) | |
# is used on the associate class (such as a Post class) - that is the migration for | |
# <tt>#{table_name}_count</tt> is created on the associate class (such that <tt>Post.comments_count</tt> will | |
# return the count cached, see note below). You can also specify a custom counter | |
# cache column by providing a column name instead of a +true+/+false+ value to this | |
# option (e.g., <tt>counter_cache: :my_custom_counter</tt>.) | |
# Note: Specifying a counter cache will add it to that model's list of readonly attributes | |
# using +attr_readonly+. | |
# [:polymorphic] | |
# Specify this association is a polymorphic association by passing +true+. | |
# Note: If you've enabled the counter cache, then you may want to add the counter cache attribute | |
# to the +attr_readonly+ list in the associated classes (e.g. <tt>class Post; attr_readonly :comments_count; end</tt>). | |
# [:validate] | |
# When set to +true+, validates new objects added to association when saving the parent object. +false+ by default. | |
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+. | |
# [:autosave] | |
# If true, always save the associated object or destroy it if marked for destruction, when | |
# saving the parent object. | |
# If false, never save or destroy the associated object. | |
# By default, only save the associated object if it's a new record. | |
# | |
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for | |
# sets <tt>:autosave</tt> to <tt>true</tt>. | |
# [:touch] | |
# If true, the associated object will be touched (the updated_at/on attributes set to current time) | |
# when this record is either saved or destroyed. If you specify a symbol, that attribute | |
# will be updated with the current time in addition to the updated_at/on attribute. | |
# Please note that with touching no validation is performed and only the +after_touch+, | |
# +after_commit+ and +after_rollback+ callbacks are executed. | |
# [:inverse_of] | |
# Specifies the name of the #has_one or #has_many association on the associated | |
# object that is the inverse of this #belongs_to association. | |
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail. | |
# [:optional] | |
# When set to +true+, the association will not have its presence validated. | |
# [:required] | |
# When set to +true+, the association will also have its presence validated. | |
# This will validate the association itself, not the id. You can use | |
# +:inverse_of+ to avoid an extra query during validation. | |
# NOTE: <tt>required</tt> is set to <tt>true</tt> by default and is deprecated. If | |
# you don't want to have association presence validated, use <tt>optional: true</tt>. | |
# [:default] | |
# Provide a callable (i.e. proc or lambda) to specify that the association should | |
# be initialized with a particular record before validation. | |
# [:strict_loading] | |
# Enforces strict loading every time the associated record is loaded through this association. | |
# [:ensuring_owner_was] | |
# Specifies an instance method to be called on the owner. The method must return true in order for the | |
# associated records to be deleted in a background job. | |
# | |
# Option examples: | |
# belongs_to :firm, foreign_key: "client_of" | |
# belongs_to :person, primary_key: "name", foreign_key: "person_name" | |
# belongs_to :author, class_name: "Person", foreign_key: "author_id" | |
# belongs_to :valid_coupon, ->(o) { where "discounts > ?", o.payments_count }, | |
# class_name: "Coupon", foreign_key: "coupon_id" | |
# belongs_to :attachable, polymorphic: true | |
# belongs_to :project, -> { readonly } | |
# belongs_to :post, counter_cache: true | |
# belongs_to :comment, touch: true | |
# belongs_to :company, touch: :employees_last_updated_at | |
# belongs_to :user, optional: true | |
# belongs_to :account, default: -> { company.account } | |
# belongs_to :account, strict_loading: true | |
def belongs_to(name, scope = nil, **options) | |
reflection = Builder::BelongsTo.build(self, name, scope, options) | |
Reflection.add_reflection self, name, reflection | |
end | |
# Specifies a many-to-many relationship with another class. This associates two classes via an | |
# intermediate join table. Unless the join table is explicitly specified as an option, it is | |
# guessed using the lexical order of the class names. So a join between Developer and Project | |
# will give the default join table name of "developers_projects" because "D" precedes "P" alphabetically. | |
# Note that this precedence is calculated using the <tt><</tt> operator for String. This | |
# means that if the strings are of different lengths, and the strings are equal when compared | |
# up to the shortest length, then the longer string is considered of higher | |
# lexical precedence than the shorter one. For example, one would expect the tables "paper_boxes" and "papers" | |
# to generate a join table name of "papers_paper_boxes" because of the length of the name "paper_boxes", | |
# but it in fact generates a join table name of "paper_boxes_papers". Be aware of this caveat, and use the | |
# custom <tt>:join_table</tt> option if you need to. | |
# If your tables share a common prefix, it will only appear once at the beginning. For example, | |
# the tables "catalog_categories" and "catalog_products" generate a join table name of "catalog_categories_products". | |
# | |
# The join table should not have a primary key or a model associated with it. You must manually generate the | |
# join table with a migration such as this: | |
# | |
# class CreateDevelopersProjectsJoinTable < ActiveRecord::Migration[7.1] | |
# def change | |
# create_join_table :developers, :projects | |
# end | |
# end | |
# | |
# It's also a good idea to add indexes to each of those columns to speed up the joins process. | |
# However, in MySQL it is advised to add a compound index for both of the columns as MySQL only | |
# uses one index per table during the lookup. | |
# | |
# Adds the following methods for retrieval and query: | |
# | |
# +collection+ is a placeholder for the symbol passed as the +name+ argument, so | |
# <tt>has_and_belongs_to_many :categories</tt> would add among others <tt>categories.empty?</tt>. | |
# | |
# [collection] | |
# Returns a Relation of all the associated objects. | |
# An empty Relation is returned if none are found. | |
# [collection<<(object, ...)] | |
# Adds one or more objects to the collection by creating associations in the join table | |
# (<tt>collection.push</tt> and <tt>collection.concat</tt> are aliases to this method). | |
# Note that this operation instantly fires update SQL without waiting for the save or update call on the | |
# parent object, unless the parent object is a new record. | |
# [collection.delete(object, ...)] | |
# Removes one or more objects from the collection by removing their associations from the join table. | |
# This does not destroy the objects. | |
# [collection.destroy(object, ...)] | |
# Removes one or more objects from the collection by running destroy on each association in the join table, overriding any dependent option. | |
# This does not destroy the objects. | |
# [collection=objects] | |
# Replaces the collection's content by deleting and adding objects as appropriate. | |
# [collection_singular_ids] | |
# Returns an array of the associated objects' ids. | |
# [collection_singular_ids=ids] | |
# Replace the collection by the objects identified by the primary keys in +ids+. | |
# [collection.clear] | |
# Removes every object from the collection. This does not destroy the objects. | |
# [collection.empty?] | |
# Returns +true+ if there are no associated objects. | |
# [collection.size] | |
# Returns the number of associated objects. | |
# [collection.find(id)] | |
# Finds an associated object responding to the +id+ and that | |
# meets the condition that it has to be associated with this object. | |
# Uses the same rules as ActiveRecord::FinderMethods#find. | |
# [collection.exists?(...)] | |
# Checks whether an associated object with the given conditions exists. | |
# Uses the same rules as ActiveRecord::FinderMethods#exists?. | |
# [collection.build(attributes = {})] | |
# Returns a new object of the collection type that has been instantiated | |
# with +attributes+ and linked to this object through the join table, but has not yet been saved. | |
# [collection.create(attributes = {})] | |
# Returns a new object of the collection type that has been instantiated | |
# with +attributes+, linked to this object through the join table, and that has already been | |
# saved (if it passed the validation). | |
# [collection.reload] | |
# Returns a Relation of all of the associated objects, forcing a database read. | |
# An empty Relation is returned if none are found. | |
# | |
# === Example | |
# | |
# A Developer class declares <tt>has_and_belongs_to_many :projects</tt>, which will add: | |
# * <tt>Developer#projects</tt> | |
# * <tt>Developer#projects<<</tt> | |
# * <tt>Developer#projects.delete</tt> | |
# * <tt>Developer#projects.destroy</tt> | |
# * <tt>Developer#projects=</tt> | |
# * <tt>Developer#project_ids</tt> | |
# * <tt>Developer#project_ids=</tt> | |
# * <tt>Developer#projects.clear</tt> | |
# * <tt>Developer#projects.empty?</tt> | |
# * <tt>Developer#projects.size</tt> | |
# * <tt>Developer#projects.find(id)</tt> | |
# * <tt>Developer#projects.exists?(...)</tt> | |
# * <tt>Developer#projects.build</tt> (similar to <tt>Project.new(developer_id: id)</tt>) | |
# * <tt>Developer#projects.create</tt> (similar to <tt>c = Project.new(developer_id: id); c.save; c</tt>) | |
# * <tt>Developer#projects.reload</tt> | |
# The declaration may include an +options+ hash to specialize the behavior of the association. | |
# | |
# === Scopes | |
# | |
# You can pass a second argument +scope+ as a callable (i.e. proc or | |
# lambda) to retrieve a specific set of records or customize the generated | |
# query when you access the associated collection. | |
# | |
# Scope examples: | |
# has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) } | |
# has_and_belongs_to_many :categories, ->(post) { | |
# where("default_category = ?", post.default_category) | |
# } | |
# | |
# === Extensions | |
# | |
# The +extension+ argument allows you to pass a block into a | |
# has_and_belongs_to_many association. This is useful for adding new | |
# finders, creators, and other factory-type methods to be used as part of | |
# the association. | |
# | |
# Extension examples: | |
# has_and_belongs_to_many :contractors do | |
# def find_or_create_by_name(name) | |
# first_name, last_name = name.split(" ", 2) | |
# find_or_create_by(first_name: first_name, last_name: last_name) | |
# end | |
# end | |
# | |
# === Options | |
# | |
# [:class_name] | |
# Specify the class name of the association. Use it only if that name can't be inferred | |
# from the association name. So <tt>has_and_belongs_to_many :projects</tt> will by default be linked to the | |
# Project class, but if the real class name is SuperProject, you'll have to specify it with this option. | |
# [:join_table] | |
# Specify the name of the join table if the default based on lexical order isn't what you want. | |
# <b>WARNING:</b> If you're overwriting the table name of either class, the +table_name+ method | |
# MUST be declared underneath any #has_and_belongs_to_many declaration in order to work. | |
# [:foreign_key] | |
# Specify the foreign key used for the association. By default this is guessed to be the name | |
# of this class in lower-case and "_id" suffixed. So a Person class that makes | |
# a #has_and_belongs_to_many association to Project will use "person_id" as the | |
# default <tt>:foreign_key</tt>. | |
# | |
# Setting the <tt>:foreign_key</tt> option prevents automatic detection of the association's | |
# inverse, so it is generally a good idea to set the <tt>:inverse_of</tt> option as well. | |
# [:association_foreign_key] | |
# Specify the foreign key used for the association on the receiving side of the association. | |
# By default this is guessed to be the name of the associated class in lower-case and "_id" suffixed. | |
# So if a Person class makes a #has_and_belongs_to_many association to Project, | |
# the association will use "project_id" as the default <tt>:association_foreign_key</tt>. | |
# [:validate] | |
# When set to +true+, validates new objects added to association when saving the parent object. +true+ by default. | |
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+. | |
# [:autosave] | |
# If true, always save the associated objects or destroy them if marked for destruction, when | |
# saving the parent object. | |
# If false, never save or destroy the associated objects. | |
# By default, only save associated objects that are new records. | |
# | |
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets | |
# <tt>:autosave</tt> to <tt>true</tt>. | |
# [:strict_loading] | |
# Enforces strict loading every time an associated record is loaded through this association. | |
# | |
# Option examples: | |
# has_and_belongs_to_many :projects | |
# has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) } | |
# has_and_belongs_to_many :nations, class_name: "Country" | |
# has_and_belongs_to_many :categories, join_table: "prods_cats" | |
# has_and_belongs_to_many :categories, -> { readonly } | |
# has_and_belongs_to_many :categories, strict_loading: true | |
def has_and_belongs_to_many(name, scope = nil, **options, &extension) | |
habtm_reflection = ActiveRecord::Reflection::HasAndBelongsToManyReflection.new(name, scope, options, self) | |
builder = Builder::HasAndBelongsToMany.new name, self, options | |
join_model = builder.through_model | |
const_set join_model.name, join_model | |
private_constant join_model.name | |
middle_reflection = builder.middle_reflection join_model | |
Builder::HasMany.define_callbacks self, middle_reflection | |
Reflection.add_reflection self, middle_reflection.name, middle_reflection | |
middle_reflection.parent_reflection = habtm_reflection | |
include Module.new { | |
class_eval <<-RUBY, __FILE__, __LINE__ + 1 | |
def destroy_associations | |
association(:#{middle_reflection.name}).delete_all(:delete_all) | |
association(:#{name}).reset | |
super | |
end | |
RUBY | |
} | |
hm_options = {} | |
hm_options[:through] = middle_reflection.name | |
hm_options[:source] = join_model.right_reflection.name | |
[:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend, :strict_loading].each do |k| | |
hm_options[k] = options[k] if options.key? k | |
end | |
has_many name, scope, **hm_options, &extension | |
_reflections[name.to_s].parent_reflection = habtm_reflection | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/computer" | |
require "models/developer" | |
require "models/project" | |
require "models/company" | |
require "models/categorization" | |
require "models/category" | |
require "models/post" | |
require "models/author" | |
require "models/book" | |
require "models/comment" | |
require "models/tag" | |
require "models/tagging" | |
require "models/person" | |
require "models/reader" | |
require "models/ship_part" | |
require "models/ship" | |
require "models/liquid" | |
require "models/molecule" | |
require "models/electron" | |
require "models/human" | |
require "models/interest" | |
require "models/pirate" | |
require "models/parrot" | |
require "models/bird" | |
require "models/treasure" | |
require "models/price_estimate" | |
require "models/invoice" | |
require "models/discount" | |
require "models/line_item" | |
require "models/shipping_line" | |
require "models/essay" | |
class AssociationsTest < ActiveRecord::TestCase | |
fixtures :accounts, :companies, :developers, :projects, :developers_projects, | |
:computers, :people, :readers, :authors, :author_addresses, :author_favorites, | |
:comments, :posts | |
def test_eager_loading_should_not_change_count_of_children | |
liquid = Liquid.create(name: "salty") | |
molecule = liquid.molecules.create(name: "molecule_1") | |
molecule.electrons.create(name: "electron_1") | |
molecule.electrons.create(name: "electron_2") | |
liquids = Liquid.includes(molecules: :electrons).references(:molecules).where("molecules.id is not null") | |
assert_equal 1, liquids[0].molecules.length | |
end | |
def test_subselect | |
author = authors :david | |
favs = author.author_favorites | |
fav2 = author.author_favorites.where(author: Author.where(id: author.id)).to_a | |
assert_equal favs, fav2 | |
end | |
def test_loading_the_association_target_should_keep_child_records_marked_for_destruction | |
ship = Ship.create!(name: "The good ship Dollypop") | |
part = ship.parts.create!(name: "Mast") | |
part.mark_for_destruction | |
assert_predicate ship.parts[0], :marked_for_destruction? | |
end | |
def test_loading_the_association_target_should_load_most_recent_attributes_for_child_records_marked_for_destruction | |
ship = Ship.create!(name: "The good ship Dollypop") | |
part = ship.parts.create!(name: "Mast") | |
part.mark_for_destruction | |
ShipPart.find(part.id).update_columns(name: "Deck") | |
assert_equal "Deck", ship.parts[0].name | |
end | |
def test_include_with_order_works | |
assert_nothing_raised { Account.all.merge!(order: "id", includes: :firm).first } | |
assert_nothing_raised { Account.all.merge!(order: :id, includes: :firm).first } | |
end | |
def test_bad_collection_keys | |
assert_raise(ArgumentError, "ActiveRecord should have barked on bad collection keys") do | |
Class.new(ActiveRecord::Base).has_many(:wheels, name: "wheels") | |
end | |
end | |
def test_should_construct_new_finder_sql_after_create | |
person = Person.new first_name: "clark" | |
assert_equal [], person.readers.to_a | |
person.save! | |
reader = Reader.create! person: person, post: Post.new(title: "foo", body: "bar") | |
assert person.readers.find(reader.id) | |
end | |
def test_force_reload | |
firm = Firm.new("name" => "A New Firm, Inc") | |
firm.save | |
firm.clients.each { } # forcing to load all clients | |
assert firm.clients.empty?, "New firm shouldn't have client objects" | |
assert_equal 0, firm.clients.size, "New firm should have 0 clients" | |
client = Client.new("name" => "TheClient.com", "firm_id" => firm.id) | |
client.save | |
assert firm.clients.empty?, "New firm should have cached no client objects" | |
assert_equal 0, firm.clients.size, "New firm should have cached 0 clients count" | |
firm.clients.reload | |
assert_not firm.clients.empty?, "New firm should have reloaded client objects" | |
assert_equal 1, firm.clients.size, "New firm should have reloaded clients count" | |
end | |
def test_using_limitable_reflections_helper | |
using_limitable_reflections = lambda { |reflections| Tagging.all.send :using_limitable_reflections?, reflections } | |
belongs_to_reflections = [Tagging.reflect_on_association(:tag), Tagging.reflect_on_association(:super_tag)] | |
has_many_reflections = [Tag.reflect_on_association(:taggings), Developer.reflect_on_association(:projects)] | |
mixed_reflections = (belongs_to_reflections + has_many_reflections).uniq | |
assert using_limitable_reflections.call(belongs_to_reflections), "Belong to associations are limitable" | |
assert_not using_limitable_reflections.call(has_many_reflections), "All has many style associations are not limitable" | |
assert_not using_limitable_reflections.call(mixed_reflections), "No collection associations (has many style) should pass" | |
end | |
def test_association_with_references | |
firm = companies(:first_firm) | |
assert_equal [:foo], firm.association_with_references.references_values | |
end | |
end | |
class AssociationProxyTest < ActiveRecord::TestCase | |
fixtures :authors, :author_addresses, :posts, :categorizations, :categories, :developers, :projects, :developers_projects | |
def test_push_does_not_load_target | |
david = authors(:david) | |
david.posts << (post = Post.new(title: "New on Edge", body: "More cool stuff!")) | |
assert_not_predicate david.posts, :loaded? | |
assert_includes david.posts, post | |
end | |
def test_push_has_many_through_does_not_load_target | |
david = authors(:david) | |
david.categories << categories(:technology) | |
assert_not_predicate david.categories, :loaded? | |
assert_includes david.categories, categories(:technology) | |
end | |
def test_push_followed_by_save_does_not_load_target | |
david = authors(:david) | |
david.posts << (post = Post.new(title: "New on Edge", body: "More cool stuff!")) | |
assert_not_predicate david.posts, :loaded? | |
david.save | |
assert_not_predicate david.posts, :loaded? | |
assert_includes david.posts, post | |
end | |
def test_push_does_not_lose_additions_to_new_record | |
josh = Author.new(name: "Josh") | |
josh.posts << Post.new(title: "New on Edge", body: "More cool stuff!") | |
assert_predicate josh.posts, :loaded? | |
assert_equal 1, josh.posts.size | |
end | |
def test_append_behaves_like_push | |
josh = Author.new(name: "Josh") | |
josh.posts.append Post.new(title: "New on Edge", body: "More cool stuff!") | |
assert_predicate josh.posts, :loaded? | |
assert_equal 1, josh.posts.size | |
end | |
def test_prepend_is_not_defined | |
josh = Author.new(name: "Josh") | |
assert_raises(NoMethodError) { josh.posts.prepend Post.new } | |
end | |
def test_save_on_parent_does_not_load_target | |
david = developers(:david) | |
assert_not_predicate david.projects, :loaded? | |
david.update_columns(created_at: Time.now) | |
assert_not_predicate david.projects, :loaded? | |
end | |
def test_load_does_load_target | |
david = developers(:david) | |
assert_not_predicate david.projects, :loaded? | |
david.projects.load | |
assert_predicate david.projects, :loaded? | |
end | |
def test_inspect_does_not_reload_a_not_yet_loaded_target | |
andreas = Developer.new name: "Andreas", log: "new developer added" | |
assert_not_predicate andreas.audit_logs, :loaded? | |
assert_match(/message: "new developer added"/, andreas.audit_logs.inspect) | |
assert_predicate andreas.audit_logs, :loaded? | |
end | |
def test_save_on_parent_saves_children | |
developer = Developer.create name: "Bryan", salary: 50_000 | |
assert_equal 1, developer.reload.audit_logs.size | |
end | |
def test_create_via_association_with_block | |
post = authors(:david).posts.create(title: "New on Edge") { |p| p.body = "More cool stuff!" } | |
assert_equal post.title, "New on Edge" | |
assert_equal post.body, "More cool stuff!" | |
end | |
def test_create_with_bang_via_association_with_block | |
post = authors(:david).posts.create!(title: "New on Edge") { |p| p.body = "More cool stuff!" } | |
assert_equal post.title, "New on Edge" | |
assert_equal post.body, "More cool stuff!" | |
end | |
def test_reload_returns_association | |
david = developers(:david) | |
assert_nothing_raised do | |
assert_equal david.projects, david.projects.reload.reload | |
end | |
end | |
def test_proxy_association_accessor | |
david = developers(:david) | |
assert_equal david.association(:projects), david.projects.proxy_association | |
end | |
def test_scoped_allows_conditions | |
assert developers(:david).projects.merge(where: "foo").to_sql.include?("foo") | |
end | |
test "getting a scope from an association" do | |
david = developers(:david) | |
assert david.projects.scope.is_a?(ActiveRecord::Relation) | |
assert_equal david.projects, david.projects.scope | |
end | |
test "proxy object is cached" do | |
david = developers(:david) | |
assert_same david.projects, david.projects | |
end | |
test "proxy object can be stubbed" do | |
david = developers(:david) | |
david.projects.define_singleton_method(:extra_method) { 42 } | |
assert_equal 42, david.projects.extra_method | |
end | |
test "inverses get set of subsets of the association" do | |
human = Human.create | |
human.interests.create | |
human = Human.find(human.id) | |
assert_queries(1) do | |
assert_equal human, human.interests.where("1=1").first.human | |
end | |
end | |
test "first! works on loaded associations" do | |
david = authors(:david) | |
assert_equal david.first_posts.first, david.first_posts.reload.first! | |
assert_predicate david.first_posts, :loaded? | |
assert_no_queries { david.first_posts.first! } | |
end | |
def test_pluck_uses_loaded_target | |
david = authors(:david) | |
assert_equal david.first_posts.pluck(:title), david.first_posts.load.pluck(:title) | |
assert_predicate david.first_posts, :loaded? | |
assert_no_queries { david.first_posts.pluck(:title) } | |
end | |
def test_pick_uses_loaded_target | |
david = authors(:david) | |
assert_equal david.first_posts.pick(:title), david.first_posts.load.pick(:title) | |
assert_predicate david.first_posts, :loaded? | |
assert_no_queries { david.first_posts.pick(:title) } | |
end | |
def test_reset_unloads_target | |
david = authors(:david) | |
david.posts.reload | |
assert_predicate david.posts, :loaded? | |
assert_predicate david.posts, :loaded | |
david.posts.reset | |
assert_not_predicate david.posts, :loaded? | |
assert_not_predicate david.posts, :loaded | |
end | |
def test_target_merging_ignores_persisted_in_memory_records | |
david = authors(:david) | |
assert david.thinking_posts.include?(posts(:thinking)) | |
david.thinking_posts.create!(title: "Something else entirely", body: "Does not matter.") | |
assert_equal 1, david.thinking_posts.size | |
assert_equal 1, david.thinking_posts.to_a.size | |
end | |
end | |
class OverridingAssociationsTest < ActiveRecord::TestCase | |
class DifferentPerson < ActiveRecord::Base; end | |
class PeopleList < ActiveRecord::Base | |
has_and_belongs_to_many :has_and_belongs_to_many, before_add: :enlist | |
has_many :has_many, before_add: :enlist | |
belongs_to :belongs_to | |
has_one :has_one | |
end | |
class DifferentPeopleList < PeopleList | |
# Different association with the same name, callbacks should be omitted here. | |
has_and_belongs_to_many :has_and_belongs_to_many, class_name: "DifferentPerson" | |
has_many :has_many, class_name: "DifferentPerson" | |
belongs_to :belongs_to, class_name: "DifferentPerson" | |
has_one :has_one, class_name: "DifferentPerson" | |
end | |
def test_habtm_association_redefinition_callbacks_should_differ_and_not_inherited | |
# redeclared association on AR descendant should not inherit callbacks from superclass | |
callbacks = PeopleList.before_add_for_has_and_belongs_to_many | |
assert_equal(1, callbacks.length) | |
callbacks = DifferentPeopleList.before_add_for_has_and_belongs_to_many | |
assert_equal([], callbacks) | |
end | |
def test_has_many_association_redefinition_callbacks_should_differ_and_not_inherited | |
# redeclared association on AR descendant should not inherit callbacks from superclass | |
callbacks = PeopleList.before_add_for_has_many | |
assert_equal(1, callbacks.length) | |
callbacks = DifferentPeopleList.before_add_for_has_many | |
assert_equal([], callbacks) | |
end | |
def test_habtm_association_redefinition_reflections_should_differ_and_not_inherited | |
assert_not_equal( | |
PeopleList.reflect_on_association(:has_and_belongs_to_many), | |
DifferentPeopleList.reflect_on_association(:has_and_belongs_to_many) | |
) | |
end | |
def test_has_many_association_redefinition_reflections_should_differ_and_not_inherited | |
assert_not_equal( | |
PeopleList.reflect_on_association(:has_many), | |
DifferentPeopleList.reflect_on_association(:has_many) | |
) | |
end | |
def test_belongs_to_association_redefinition_reflections_should_differ_and_not_inherited | |
assert_not_equal( | |
PeopleList.reflect_on_association(:belongs_to), | |
DifferentPeopleList.reflect_on_association(:belongs_to) | |
) | |
end | |
def test_has_one_association_redefinition_reflections_should_differ_and_not_inherited | |
assert_not_equal( | |
PeopleList.reflect_on_association(:has_one), | |
DifferentPeopleList.reflect_on_association(:has_one) | |
) | |
end | |
def test_requires_symbol_argument | |
assert_raises ArgumentError do | |
Class.new(Post) do | |
belongs_to "author" | |
end | |
end | |
end | |
class ModelAssociatedToClassesThatDoNotExist < ActiveRecord::Base | |
self.table_name = "accounts" # this is just to avoid adding a new model just for this test | |
has_one :non_existent_has_one_class | |
belongs_to :non_existent_belongs_to_class | |
has_many :non_existent_has_many_classes | |
end | |
def test_associations_raise_with_name_error_if_associated_to_classes_that_do_not_exist | |
assert_raises NameError do | |
ModelAssociatedToClassesThatDoNotExist.new.non_existent_has_one_class | |
end | |
assert_raises NameError do | |
ModelAssociatedToClassesThatDoNotExist.new.non_existent_belongs_to_class | |
end | |
assert_raises NameError do | |
ModelAssociatedToClassesThatDoNotExist.new.non_existent_has_many_classes | |
end | |
end | |
end | |
class PreloaderTest < ActiveRecord::TestCase | |
fixtures :posts, :comments, :books, :authors, :tags, :taggings, :essays, :categories, :author_addresses | |
def test_preload_with_scope | |
post = posts(:welcome) | |
preloader = ActiveRecord::Associations::Preloader.new(records: [post], associations: :comments, scope: Comment.where(body: "Thank you for the welcome")) | |
preloader.call | |
assert_predicate post.comments, :loaded? | |
assert_equal [comments(:greetings)], post.comments | |
end | |
def test_preload_makes_correct_number_of_queries_on_array | |
post = posts(:welcome) | |
assert_queries(1) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [post], associations: :comments) | |
preloader.call | |
end | |
end | |
def test_preload_makes_correct_number_of_queries_on_relation | |
post = posts(:welcome) | |
relation = Post.where(id: post.id) | |
assert_queries(2) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: relation, associations: :comments) | |
preloader.call | |
end | |
end | |
def test_preload_for_hmt_with_conditions | |
post = posts(:welcome) | |
_normal_category = post.categories.create!(name: "Normal") | |
special_category = post.special_categories.create!(name: "Special") | |
preloader = ActiveRecord::Associations::Preloader.new(records: [post], associations: :hmt_special_categories) | |
preloader.call | |
assert_equal 1, post.hmt_special_categories.length | |
assert_equal [special_category], post.hmt_special_categories | |
end | |
def test_preload_groups_queries_with_same_scope | |
book = books(:awdr) | |
post = posts(:welcome) | |
assert_queries(1) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [book, post], associations: :author) | |
preloader.call | |
end | |
assert_no_queries do | |
book.author | |
post.author | |
end | |
end | |
def test_preload_grouped_queries_with_already_loaded_records | |
book = books(:awdr) | |
post = posts(:welcome) | |
book.author | |
assert_no_queries do | |
ActiveRecord::Associations::Preloader.new(records: [book, post], associations: :author).call | |
book.author | |
post.author | |
end | |
end | |
def test_preload_grouped_queries_of_middle_records | |
comments = [ | |
comments(:eager_sti_on_associations_s_comment1), | |
comments(:eager_sti_on_associations_s_comment2), | |
] | |
assert_queries(2) do | |
ActiveRecord::Associations::Preloader.new(records: comments, associations: [:author, :ordinary_post]).call | |
end | |
end | |
def test_preload_grouped_queries_of_through_records | |
author = authors(:david) | |
assert_queries(3) do | |
ActiveRecord::Associations::Preloader.new(records: [author], associations: [:hello_post_comments, :comments]).call | |
end | |
end | |
def test_preload_with_instance_dependent_scope | |
david = authors(:david) | |
david2 = Author.create!(name: "David") | |
bob = authors(:bob) | |
post = Post.create!( | |
author: david, | |
title: "test post", | |
body: "this post is about David" | |
) | |
post2 = Post.create!( | |
author: david, | |
title: "test post 2", | |
body: "this post is also about David" | |
) | |
assert_queries(2) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [david, david2, bob], associations: :posts_mentioning_author) | |
preloader.call | |
end | |
assert_predicate david.posts_mentioning_author, :loaded? | |
assert_predicate david2.posts_mentioning_author, :loaded? | |
assert_predicate bob.posts_mentioning_author, :loaded? | |
assert_equal [post, post2].sort, david.posts_mentioning_author.sort | |
assert_equal [], david2.posts_mentioning_author | |
assert_equal [], bob.posts_mentioning_author | |
end | |
def test_preload_with_instance_dependent_through_scope | |
david = authors(:david) | |
david2 = Author.create!(name: "David") | |
bob = authors(:bob) | |
comment1 = david.posts.first.comments.create!(body: "Hi David!") | |
comment2 = david.posts.first.comments.create!(body: "This comment mentions david") | |
assert_queries(2) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [david, david2, bob], associations: :comments_mentioning_author) | |
preloader.call | |
end | |
assert_predicate david.comments_mentioning_author, :loaded? | |
assert_predicate david2.comments_mentioning_author, :loaded? | |
assert_predicate bob.comments_mentioning_author, :loaded? | |
assert_equal [comment1, comment2].sort, david.comments_mentioning_author.sort | |
assert_equal [], david2.comments_mentioning_author | |
assert_equal [], bob.comments_mentioning_author | |
end | |
def test_preload_with_through_instance_dependent_scope | |
david = authors(:david) | |
david2 = Author.create!(name: "David") | |
bob = authors(:bob) | |
post = Post.create!( | |
author: david, | |
title: "test post", | |
body: "this post is about David" | |
) | |
Post.create!( | |
author: david, | |
title: "test post 2", | |
body: "this post is also about David" | |
) | |
post3 = Post.create!( | |
author: bob, | |
title: "test post 3", | |
body: "this post is about Bob" | |
) | |
comment1 = post.comments.create!(body: "hi!") | |
comment2 = post.comments.create!(body: "hello!") | |
comment3 = post3.comments.create!(body: "HI BOB!") | |
assert_queries(3) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [david, david2, bob], associations: :comments_on_posts_mentioning_author) | |
preloader.call | |
end | |
assert_predicate david.comments_on_posts_mentioning_author, :loaded? | |
assert_predicate david2.comments_on_posts_mentioning_author, :loaded? | |
assert_predicate bob.comments_on_posts_mentioning_author, :loaded? | |
assert_equal [comment1, comment2].sort, david.comments_on_posts_mentioning_author.sort | |
assert_equal [], david2.comments_on_posts_mentioning_author | |
assert_equal [comment3], bob.comments_on_posts_mentioning_author | |
end | |
def test_some_already_loaded_associations | |
item_discount = Discount.create(amount: 5) | |
shipping_discount = Discount.create(amount: 20) | |
invoice = Invoice.new | |
line_item = LineItem.new(amount: 20) | |
line_item.discount_applications << LineItemDiscountApplication.new(discount: item_discount) | |
invoice.line_items << line_item | |
shipping_line = ShippingLine.new(amount: 50) | |
shipping_line.discount_applications << ShippingLineDiscountApplication.new(discount: shipping_discount) | |
invoice.shipping_lines << shipping_line | |
invoice.save! | |
invoice.reload | |
# SELECT "line_items".* FROM "line_items" WHERE "line_items"."invoice_id" = ? | |
# SELECT "shipping_lines".* FROM shipping_lines WHERE "shipping_lines"."invoice_id" = ? | |
# SELECT "line_item_discount_applications".* FROM "line_item_discount_applications" WHERE "line_item_discount_applications"."line_item_id" = ? | |
# SELECT "shipping_line_discount_applications".* FROM "shipping_line_discount_applications" WHERE "shipping_line_discount_applications"."shipping_line_id" = ? | |
# SELECT "discounts".* FROM "discounts" WHERE "discounts"."id" IN (?, ?). | |
assert_queries(5) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [invoice], associations: [ | |
line_items: { discount_applications: :discount }, | |
shipping_lines: { discount_applications: :discount }, | |
]) | |
preloader.call | |
end | |
assert_no_queries do | |
assert_not_nil invoice.line_items.first.discount_applications.first.discount | |
assert_not_nil invoice.shipping_lines.first.discount_applications.first.discount | |
end | |
invoice.reload | |
invoice.line_items.map { |i| i.discount_applications.to_a } | |
# `line_items` and `line_item_discount_applications` are already preloaded, so we expect: | |
# SELECT "shipping_lines".* FROM shipping_lines WHERE "shipping_lines"."invoice_id" = ? | |
# SELECT "shipping_line_discount_applications".* FROM "shipping_line_discount_applications" WHERE "shipping_line_discount_applications"."shipping_line_id" = ? | |
# SELECT "discounts".* FROM "discounts" WHERE "discounts"."id" = ?. | |
assert_queries(3) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [invoice], associations: [ | |
line_items: { discount_applications: :discount }, | |
shipping_lines: { discount_applications: :discount }, | |
]) | |
preloader.call | |
end | |
assert_no_queries do | |
assert_not_nil invoice.line_items.first.discount_applications.first.discount | |
assert_not_nil invoice.shipping_lines.first.discount_applications.first.discount | |
end | |
end | |
def test_preload_through | |
comments = [ | |
comments(:eager_sti_on_associations_s_comment1), | |
comments(:eager_sti_on_associations_s_comment2), | |
] | |
assert_queries(2) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: comments, associations: [:author, :post]) | |
preloader.call | |
end | |
assert_no_queries do | |
comments.each(&:author) | |
end | |
end | |
def test_preload_groups_queries_with_same_scope_at_second_level | |
author = nil | |
# Expected | |
# SELECT FROM authors ... | |
# SELECT FROM posts ... (thinking) | |
# SELECT FROM posts ... (welcome) | |
# SELECT FROM comments ... (comments for both welcome and thinking) | |
assert_queries(4) do | |
author = Author | |
.where(name: "David") | |
.includes(thinking_posts: :comments, welcome_posts: :comments) | |
.first | |
end | |
assert_no_queries do | |
author.thinking_posts.map(&:comments) | |
author.welcome_posts.map(&:comments) | |
end | |
end | |
def test_preload_groups_queries_with_same_sql_at_second_level | |
author = nil | |
# Expected | |
# SELECT FROM authors ... | |
# SELECT FROM posts ... (thinking) | |
# SELECT FROM posts ... (welcome) | |
# SELECT FROM comments ... (comments for both welcome and thinking) | |
assert_queries(4) do | |
author = Author | |
.where(name: "David") | |
.includes(thinking_posts: :comments, welcome_posts: :comments_with_extending) | |
.first | |
end | |
assert_no_queries do | |
author.thinking_posts.map(&:comments) | |
author.welcome_posts.map(&:comments_with_extending) | |
end | |
end | |
def test_preload_with_grouping_sets_inverse_association | |
mary = authors(:mary) | |
bob = authors(:bob) | |
AuthorFavorite.create!(author: mary, favorite_author: bob) | |
favorites = AuthorFavorite.all.load | |
assert_queries(1) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: favorites, associations: [:author, :favorite_author]) | |
preloader.call | |
end | |
assert_no_queries do | |
favorites.first.author | |
favorites.first.favorite_author | |
end | |
end | |
def test_preload_can_group_separate_levels | |
mary = authors(:mary) | |
bob = authors(:bob) | |
AuthorFavorite.create!(author: mary, favorite_author: bob) | |
assert_queries(3) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: [:posts, favorite_authors: :posts]) | |
preloader.call | |
end | |
assert_no_queries do | |
mary.posts | |
mary.favorite_authors.map(&:posts) | |
end | |
end | |
def test_preload_can_group_multi_level_ping_pong_through | |
mary = authors(:mary) | |
bob = authors(:bob) | |
AuthorFavorite.create!(author: mary, favorite_author: bob) | |
associations = { similar_posts: :comments, favorite_authors: { similar_posts: :comments } } | |
assert_queries(9) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: associations) | |
preloader.call | |
end | |
assert_no_queries do | |
mary.similar_posts.map(&:comments).each(&:to_a) | |
mary.favorite_authors.flat_map(&:similar_posts).map(&:comments).each(&:to_a) | |
end | |
# Preloading with automatic scope inversing reduces the number of queries | |
tag_reflection = Tagging.reflect_on_association(:tag) | |
taggings_reflection = Tag.reflect_on_association(:taggings) | |
assert tag_reflection.scope | |
assert_not taggings_reflection.scope | |
with_automatic_scope_inversing(tag_reflection, taggings_reflection) do | |
mary.reload | |
assert_queries(8) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: associations) | |
preloader.call | |
end | |
end | |
end | |
def test_preload_does_not_group_same_class_different_scope | |
post = posts(:welcome) | |
postesque = Postesque.create(author: Author.last) | |
postesque.reload | |
# When the scopes differ in the generated SQL: | |
# SELECT "authors".* FROM "authors" WHERE (name LIKE '%a%') AND "authors"."id" = ? | |
# SELECT "authors".* FROM "authors" WHERE "authors"."id" = ?. | |
assert_queries(2) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [post, postesque], associations: :author_with_the_letter_a) | |
preloader.call | |
end | |
assert_no_queries do | |
post.author_with_the_letter_a | |
postesque.author_with_the_letter_a | |
end | |
post.reload | |
postesque.reload | |
# When the generated SQL is identical, but one scope has preload values. | |
assert_queries(3) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [post, postesque], associations: :author_with_address) | |
preloader.call | |
end | |
assert_no_queries do | |
post.author_with_address | |
postesque.author_with_address | |
end | |
end | |
def test_preload_does_not_group_same_scope_different_key_name | |
post = posts(:welcome) | |
postesque = Postesque.create(author: Author.last) | |
postesque.reload | |
assert_queries(2) do | |
preloader = ActiveRecord::Associations::Preloader.new(records: [post, postesque], associations: :author) | |
preloader.call | |
end | |
assert_no_queries do | |
post.author | |
postesque.author | |
end | |
end | |
def test_preload_with_available_records | |
post = posts(:welcome) | |
david = authors(:david) | |
assert_no_queries do | |
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, available_records: [[david]]).call | |
assert_predicate post.association(:author), :loaded? | |
assert_same david, post.author | |
end | |
end | |
def test_preload_with_available_records_sti | |
book = Book.create! | |
essay_special = EssaySpecial.create! | |
book.essay = essay_special | |
book.save! | |
book.reload | |
assert_not_predicate book.association(:essay), :loaded? | |
assert_no_queries do | |
ActiveRecord::Associations::Preloader.new(records: [book], associations: :essay, available_records: [[essay_special]]).call | |
end | |
assert_predicate book.association(:essay), :loaded? | |
assert_same essay_special, book.essay | |
end | |
def test_preload_with_only_some_records_available | |
bob_post = posts(:misc_by_bob) | |
mary_post = posts(:misc_by_mary) | |
bob = authors(:bob) | |
mary = authors(:mary) | |
assert_queries(1) do | |
ActiveRecord::Associations::Preloader.new(records: [bob_post, mary_post], associations: :author, available_records: [bob]).call | |
end | |
assert_no_queries do | |
assert_same bob, bob_post.author | |
assert_equal mary, mary_post.author | |
end | |
end | |
def test_preload_with_some_records_already_loaded | |
bob_post = posts(:misc_by_bob) | |
mary_post = posts(:misc_by_mary) | |
bob = bob_post.author | |
mary = authors(:mary) | |
assert bob_post.association(:author).loaded? | |
assert_not mary_post.association(:author).loaded? | |
assert_queries(1) do | |
ActiveRecord::Associations::Preloader.new(records: [bob_post, mary_post], associations: :author).call | |
end | |
assert_no_queries do | |
assert_same bob, bob_post.author | |
assert_equal mary, mary_post.author | |
end | |
end | |
def test_preload_with_available_records_with_through_association | |
author = authors(:david) | |
categories = Category.all.to_a | |
assert_queries(1) do | |
# One query to get the middle records (i.e. essays) | |
ActiveRecord::Associations::Preloader.new(records: [author], associations: :essay_category, available_records: categories).call | |
end | |
assert_predicate author.association(:essay_category), :loaded? | |
assert categories.map(&:object_id).include?(author.essay_category.object_id) | |
end | |
def test_preload_with_only_some_records_available_with_through_associations | |
mary = authors(:mary) | |
mary_essay = essays(:mary_stay_home) | |
mary_category = categories(:technology) | |
mary_essay.update!(category: mary_category) | |
dave = authors(:david) | |
dave_category = categories(:general) | |
assert_queries(2) do | |
ActiveRecord::Associations::Preloader.new(records: [mary, dave], associations: :essay_category, available_records: [mary_category]).call | |
end | |
assert_no_queries do | |
assert_same mary_category, mary.essay_category | |
assert_equal dave_category, dave.essay_category | |
end | |
end | |
def test_preload_with_available_records_with_multiple_classes | |
essay = essays(:david_modest_proposal) | |
general = categories(:general) | |
david = authors(:david) | |
assert_no_queries do | |
ActiveRecord::Associations::Preloader.new(records: [essay], associations: [:category, :author], available_records: [general, david]).call | |
assert_predicate essay.association(:category), :loaded? | |
assert_predicate essay.association(:author), :loaded? | |
assert_same general, essay.category | |
assert_same david, essay.author | |
end | |
end | |
def test_preload_with_available_records_queries_when_scoped | |
post = posts(:welcome) | |
david = authors(:david) | |
assert_queries(1) do | |
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, scope: Author.where(name: "David"), available_records: [david]).call | |
end | |
assert_predicate post.association(:author), :loaded? | |
assert_not_equal david.object_id, post.author.object_id | |
end | |
def test_preload_with_available_records_queries_when_collection | |
post = posts(:welcome) | |
comments = Comment.all.to_a | |
assert_queries(1) do | |
ActiveRecord::Associations::Preloader.new(records: [post], associations: :comments, available_records: comments).call | |
end | |
assert_predicate post.association(:comments), :loaded? | |
assert_empty post.comments.map(&:object_id) & comments.map(&:object_id) | |
end | |
def test_preload_with_available_records_queries_when_incomplete | |
post = posts(:welcome) | |
bob = authors(:bob) | |
david = authors(:david) | |
assert_queries(1) do | |
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, available_records: [bob]).call | |
end | |
assert_no_queries do | |
assert_predicate post.association(:author), :loaded? | |
assert_equal david, post.author | |
end | |
end | |
def test_preload_with_unpersisted_records_no_ops | |
author = Author.new | |
new_post_with_author = Post.new(author: author) | |
new_post_without_author = Post.new | |
posts = [new_post_with_author, new_post_without_author] | |
assert_no_queries do | |
ActiveRecord::Associations::Preloader.new(records: posts, associations: :author).call | |
assert_same author, new_post_with_author.author | |
assert_nil new_post_without_author.author | |
end | |
end | |
def test_preload_wont_set_the_wrong_target | |
post = posts(:welcome) | |
post.update!(author_id: 54321) | |
some_other_record = categories(:general) | |
some_other_record.update!(id: 54321) | |
assert_raises do | |
some_other_record.association(:author) | |
end | |
assert_nothing_raised do | |
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, available_records: [[some_other_record]]).call | |
assert post.association(:author).loaded? | |
assert_not_equal some_other_record, post.author | |
end | |
end | |
end | |
class GeneratedMethodsTest < ActiveRecord::TestCase | |
fixtures :developers, :computers, :posts, :comments | |
def test_association_methods_override_attribute_methods_of_same_name | |
assert_equal(developers(:david), computers(:workstation).developer) | |
# this next line will fail if the attribute methods module is generated lazily | |
# after the association methods module is generated | |
assert_equal(developers(:david), computers(:workstation).developer) | |
assert_equal(developers(:david).id, computers(:workstation)[:developer]) | |
end | |
def test_model_method_overrides_association_method | |
assert_equal(comments(:greetings).body, posts(:welcome).first_comment) | |
end | |
module MyModule | |
def comments; :none end | |
end | |
class MyArticle < ActiveRecord::Base | |
self.table_name = "articles" | |
include MyModule | |
has_many :comments, inverse_of: false | |
end | |
def test_included_module_overwrites_association_methods | |
assert_equal :none, MyArticle.new.comments | |
end | |
end | |
class WithAnnotationsTest < ActiveRecord::TestCase | |
fixtures :pirates, :parrots | |
def test_belongs_to_with_annotation_includes_a_query_comment | |
pirate = SpacePirate.where.not(parrot_id: nil).first | |
assert pirate, "should have a Pirate record" | |
log = capture_sql do | |
pirate.parrot | |
end | |
assert_not_predicate log, :empty? | |
assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? | |
assert_sql(%r{/\* that tells jokes \*/}) do | |
pirate.parrot_with_annotation | |
end | |
end | |
def test_has_and_belongs_to_many_with_annotation_includes_a_query_comment | |
pirate = SpacePirate.first | |
assert pirate, "should have a Pirate record" | |
log = capture_sql do | |
pirate.parrots.first | |
end | |
assert_not_predicate log, :empty? | |
assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? | |
assert_sql(%r{/\* that are very colorful \*/}) do | |
pirate.parrots_with_annotation.first | |
end | |
end | |
def test_has_one_with_annotation_includes_a_query_comment | |
pirate = SpacePirate.first | |
assert pirate, "should have a Pirate record" | |
log = capture_sql do | |
pirate.ship | |
end | |
assert_not_predicate log, :empty? | |
assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? | |
assert_sql(%r{/\* that is a rocket \*/}) do | |
pirate.ship_with_annotation | |
end | |
end | |
def test_has_many_with_annotation_includes_a_query_comment | |
pirate = SpacePirate.first | |
assert pirate, "should have a Pirate record" | |
log = capture_sql do | |
pirate.birds.first | |
end | |
assert_not_predicate log, :empty? | |
assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? | |
assert_sql(%r{/\* that are also parrots \*/}) do | |
pirate.birds_with_annotation.first | |
end | |
end | |
def test_has_many_through_with_annotation_includes_a_query_comment | |
pirate = SpacePirate.first | |
assert pirate, "should have a Pirate record" | |
log = capture_sql do | |
pirate.treasure_estimates.first | |
end | |
assert_not_predicate log, :empty? | |
assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? | |
assert_sql(%r{/\* yarrr \*/}) do | |
pirate.treasure_estimates_with_annotation.first | |
end | |
end | |
def test_has_many_through_with_annotation_includes_a_query_comment_when_eager_loading | |
pirate = SpacePirate.first | |
assert pirate, "should have a Pirate record" | |
log = capture_sql do | |
pirate.treasure_estimates.first | |
end | |
assert_not_predicate log, :empty? | |
assert_predicate log.select { |query| query.match?(%r{/\*}) }, :empty? | |
assert_sql(%r{/\* yarrr \*/}) do | |
SpacePirate.includes(:treasure_estimates_with_annotation, :treasures).first | |
end | |
end | |
end |
# frozen_string_literal: true | |
module AsyncJobsManager | |
def setup | |
ActiveJob::Base.queue_adapter = :async | |
ActiveJob::Base.queue_adapter.immediate = false | |
end | |
def clear_jobs | |
ActiveJob::Base.queue_adapter.shutdown | |
end | |
end |
# frozen_string_literal: true | |
require "securerandom" | |
require "concurrent/scheduled_task" | |
require "concurrent/executor/thread_pool_executor" | |
require "concurrent/utility/processor_counter" | |
module ActiveJob | |
module QueueAdapters | |
# == Active Job Async adapter | |
# | |
# The Async adapter runs jobs with an in-process thread pool. | |
# | |
# This is the default queue adapter. It's well-suited for dev/test since | |
# it doesn't need an external infrastructure, but it's a poor fit for | |
# production since it drops pending jobs on restart. | |
# | |
# To use this adapter, set queue adapter to +:async+: | |
# | |
# config.active_job.queue_adapter = :async | |
# | |
# To configure the adapter's thread pool, instantiate the adapter and | |
# pass your own config: | |
# | |
# config.active_job.queue_adapter = ActiveJob::QueueAdapters::AsyncAdapter.new \ | |
# min_threads: 1, | |
# max_threads: 2 * Concurrent.processor_count, | |
# idletime: 600.seconds | |
# | |
# The adapter uses a {Concurrent Ruby}[https://github.com/ruby-concurrency/concurrent-ruby] thread pool to schedule and execute | |
# jobs. Since jobs share a single thread pool, long-running jobs will block | |
# short-lived jobs. Fine for dev/test; bad for production. | |
class AsyncAdapter | |
# See {Concurrent::ThreadPoolExecutor}[https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/ThreadPoolExecutor.html] for executor options. | |
def initialize(**executor_options) | |
@scheduler = Scheduler.new(**executor_options) | |
end | |
def enqueue(job) # :nodoc: | |
@scheduler.enqueue JobWrapper.new(job), queue_name: job.queue_name | |
end | |
def enqueue_at(job, timestamp) # :nodoc: | |
@scheduler.enqueue_at JobWrapper.new(job), timestamp, queue_name: job.queue_name | |
end | |
# Gracefully stop processing jobs. Finishes in-progress work and handles | |
# any new jobs following the executor's fallback policy (`caller_runs`). | |
# Waits for termination by default. Pass `wait: false` to continue. | |
def shutdown(wait: true) # :nodoc: | |
@scheduler.shutdown wait: wait | |
end | |
# Used for our test suite. | |
def immediate=(immediate) # :nodoc: | |
@scheduler.immediate = immediate | |
end | |
# Note that we don't actually need to serialize the jobs since we're | |
# performing them in-process, but we do so anyway for parity with other | |
# adapters and deployment environments. Otherwise, serialization bugs | |
# may creep in undetected. | |
class JobWrapper # :nodoc: | |
def initialize(job) | |
job.provider_job_id = SecureRandom.uuid | |
@job_data = job.serialize | |
end | |
def perform | |
Base.execute @job_data | |
end | |
end | |
class Scheduler # :nodoc: | |
DEFAULT_EXECUTOR_OPTIONS = { | |
min_threads: 0, | |
max_threads: Concurrent.processor_count, | |
auto_terminate: true, | |
idletime: 60, # 1 minute | |
max_queue: 0, # unlimited | |
fallback_policy: :caller_runs # shouldn't matter -- 0 max queue | |
}.freeze | |
attr_accessor :immediate | |
def initialize(**options) | |
self.immediate = false | |
@immediate_executor = Concurrent::ImmediateExecutor.new | |
@async_executor = Concurrent::ThreadPoolExecutor.new(DEFAULT_EXECUTOR_OPTIONS.merge(options)) | |
end | |
def enqueue(job, queue_name:) | |
executor.post(job, &:perform) | |
end | |
def enqueue_at(job, timestamp, queue_name:) | |
delay = timestamp - Time.current.to_f | |
if delay > 0 | |
Concurrent::ScheduledTask.execute(delay, args: [job], executor: executor, &:perform) | |
else | |
enqueue(job, queue_name: queue_name) | |
end | |
end | |
def shutdown(wait: true) | |
@async_executor.shutdown | |
@async_executor.wait_for_termination if wait | |
end | |
def executor | |
immediate ? @immediate_executor : @async_executor | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "support/connection_helper" | |
require "models/post" | |
module AsynchronousQueriesSharedTests | |
def test_async_select_failure | |
ActiveRecord::Base.asynchronous_queries_tracker.start_session | |
if in_memory_db? | |
assert_raises ActiveRecord::StatementInvalid do | |
@connection.select_all "SELECT * FROM does_not_exists", async: true | |
end | |
else | |
future_result = @connection.select_all "SELECT * FROM does_not_exists", async: true | |
assert_kind_of ActiveRecord::FutureResult, future_result | |
assert_raises ActiveRecord::StatementInvalid do | |
future_result.result | |
end | |
end | |
ensure | |
ActiveRecord::Base.asynchronous_queries_tracker.finalize_session | |
end | |
def test_async_query_from_transaction | |
ActiveRecord::Base.asynchronous_queries_tracker.start_session | |
assert_nothing_raised do | |
@connection.select_all "SELECT * FROM posts", async: true | |
end | |
unless in_memory_db? | |
@connection.transaction do | |
assert_raises ActiveRecord::AsynchronousQueryInsideTransactionError do | |
@connection.select_all "SELECT * FROM posts", async: true | |
end | |
end | |
end | |
ensure | |
ActiveRecord::Base.asynchronous_queries_tracker.finalize_session | |
end | |
def test_async_query_cache | |
ActiveRecord::Base.asynchronous_queries_tracker.start_session | |
@connection.enable_query_cache! | |
@connection.select_all "SELECT * FROM posts" | |
result = @connection.select_all "SELECT * FROM posts", async: true | |
assert_equal ActiveRecord::Result, result.class | |
ensure | |
ActiveRecord::Base.asynchronous_queries_tracker.finalize_session | |
@connection.disable_query_cache! | |
end | |
def test_async_query_foreground_fallback | |
status = {} | |
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event| | |
if event.payload[:sql] == "SELECT * FROM does_not_exists" | |
status[:executed] = true | |
status[:async] = event.payload[:async] | |
end | |
end | |
@connection.pool.stub(:schedule_query, proc { }) do | |
if in_memory_db? | |
assert_raises ActiveRecord::StatementInvalid do | |
@connection.select_all "SELECT * FROM does_not_exists", async: true | |
end | |
else | |
future_result = @connection.select_all "SELECT * FROM does_not_exists", async: true | |
assert_kind_of ActiveRecord::FutureResult, future_result | |
assert_raises ActiveRecord::StatementInvalid do | |
future_result.result | |
end | |
end | |
end | |
assert_equal true, status[:executed] | |
assert_equal false, status[:async] | |
ensure | |
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber | |
end | |
private | |
def wait_for_future_result(result) | |
100.times do | |
break unless result.pending? | |
sleep 0.01 | |
end | |
end | |
end | |
class AsynchronousQueriesTest < ActiveRecord::TestCase | |
self.use_transactional_tests = false | |
include AsynchronousQueriesSharedTests | |
def setup | |
@connection = ActiveRecord::Base.connection | |
end | |
def test_async_select_all | |
ActiveRecord::Base.asynchronous_queries_tracker.start_session | |
status = {} | |
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event| | |
if event.payload[:sql] == "SELECT * FROM posts" | |
status[:executed] = true | |
status[:async] = event.payload[:async] | |
end | |
end | |
future_result = @connection.select_all "SELECT * FROM posts", async: true | |
if in_memory_db? | |
assert_kind_of ActiveRecord::Result, future_result | |
else | |
assert_kind_of ActiveRecord::FutureResult, future_result | |
wait_for_future_result(future_result) | |
end | |
assert_kind_of ActiveRecord::Result, future_result.result | |
assert_equal @connection.supports_concurrent_connections?, status[:async] | |
ensure | |
ActiveRecord::Base.asynchronous_queries_tracker.finalize_session | |
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber | |
end | |
end | |
class AsynchronousQueriesWithTransactionalTest < ActiveRecord::TestCase | |
self.use_transactional_tests = true | |
include AsynchronousQueriesSharedTests | |
def setup | |
@connection = ActiveRecord::Base.connection | |
@connection.materialize_transactions | |
end | |
end | |
class AsynchronousExecutorTypeTest < ActiveRecord::TestCase | |
def test_null_configuration_uses_a_single_null_executor_by_default | |
old_value = ActiveRecord.async_query_executor | |
ActiveRecord.async_query_executor = nil | |
handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") | |
db_config2 = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary") | |
pool1 = handler.establish_connection(db_config) | |
pool2 = handler.establish_connection(db_config2, owner_name: ARUnit2Model) | |
async_pool1 = pool1.instance_variable_get(:@async_executor) | |
async_pool2 = pool2.instance_variable_get(:@async_executor) | |
assert_nil async_pool1 | |
assert_nil async_pool2 | |
assert_equal 2, handler.all_connection_pools.count | |
ensure | |
clean_up_connection_handler | |
ActiveRecord.async_query_executor = old_value | |
end | |
def test_one_global_thread_pool_is_used_when_set_with_default_concurrency | |
old_value = ActiveRecord.async_query_executor | |
ActiveRecord.async_query_executor = :global_thread_pool | |
handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") | |
db_config2 = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary") | |
pool1 = handler.establish_connection(db_config) | |
pool2 = handler.establish_connection(db_config2, owner_name: ARUnit2Model) | |
async_pool1 = pool1.instance_variable_get(:@async_executor) | |
async_pool2 = pool2.instance_variable_get(:@async_executor) | |
assert async_pool1.is_a?(Concurrent::ThreadPoolExecutor) | |
assert async_pool2.is_a?(Concurrent::ThreadPoolExecutor) | |
assert_equal 0, async_pool1.min_length | |
assert_equal 4, async_pool1.max_length | |
assert_equal 16, async_pool1.max_queue | |
assert_equal :caller_runs, async_pool1.fallback_policy | |
assert_equal 0, async_pool2.min_length | |
assert_equal 4, async_pool2.max_length | |
assert_equal 16, async_pool2.max_queue | |
assert_equal :caller_runs, async_pool2.fallback_policy | |
assert_equal 2, handler.all_connection_pools.count | |
assert_equal async_pool1, async_pool2 | |
ensure | |
clean_up_connection_handler | |
ActiveRecord.async_query_executor = old_value | |
end | |
def test_concurrency_can_be_set_on_global_thread_pool | |
old_value = ActiveRecord.async_query_executor | |
ActiveRecord.async_query_executor = :global_thread_pool | |
old_concurrency = ActiveRecord.global_executor_concurrency | |
old_global_thread_pool_async_query_executor = ActiveRecord.instance_variable_get(:@global_thread_pool_async_query_executor) | |
ActiveRecord.instance_variable_set(:@global_thread_pool_async_query_executor, nil) | |
ActiveRecord.global_executor_concurrency = 8 | |
handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") | |
db_config2 = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary") | |
pool1 = handler.establish_connection(db_config) | |
pool2 = handler.establish_connection(db_config2, owner_name: ARUnit2Model) | |
async_pool1 = pool1.instance_variable_get(:@async_executor) | |
async_pool2 = pool2.instance_variable_get(:@async_executor) | |
assert async_pool1.is_a?(Concurrent::ThreadPoolExecutor) | |
assert async_pool2.is_a?(Concurrent::ThreadPoolExecutor) | |
assert_equal 0, async_pool1.min_length | |
assert_equal 8, async_pool1.max_length | |
assert_equal 32, async_pool1.max_queue | |
assert_equal :caller_runs, async_pool1.fallback_policy | |
assert_equal 0, async_pool2.min_length | |
assert_equal 8, async_pool2.max_length | |
assert_equal 32, async_pool2.max_queue | |
assert_equal :caller_runs, async_pool2.fallback_policy | |
assert_equal 2, handler.all_connection_pools.count | |
assert_equal async_pool1, async_pool2 | |
ensure | |
clean_up_connection_handler | |
ActiveRecord.global_executor_concurrency = old_concurrency | |
ActiveRecord.async_query_executor = old_value | |
ActiveRecord.instance_variable_set(:@global_thread_pool_async_query_executor, old_global_thread_pool_async_query_executor) | |
end | |
def test_concurrency_cannot_be_set_with_null_executor_or_multi_thread_pool | |
old_value = ActiveRecord.async_query_executor | |
ActiveRecord.async_query_executor = nil | |
assert_raises ArgumentError do | |
ActiveRecord.global_executor_concurrency = 8 | |
end | |
ActiveRecord.async_query_executor = :multi_thread_pool | |
assert_raises ArgumentError do | |
ActiveRecord.global_executor_concurrency = 8 | |
end | |
ensure | |
ActiveRecord.async_query_executor = old_value | |
end | |
def test_multi_thread_pool_executor_configuration | |
old_value = ActiveRecord.async_query_executor | |
ActiveRecord.async_query_executor = :multi_thread_pool | |
handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
config_hash = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary").configuration_hash | |
new_config_hash = config_hash.merge(min_threads: 0, max_threads: 10) | |
db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("arunit", "primary", new_config_hash) | |
db_config2 = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary") | |
pool1 = handler.establish_connection(db_config) | |
pool2 = handler.establish_connection(db_config2, owner_name: ARUnit2Model) | |
async_pool1 = pool1.instance_variable_get(:@async_executor) | |
async_pool2 = pool2.instance_variable_get(:@async_executor) | |
assert async_pool1.is_a?(Concurrent::ThreadPoolExecutor) | |
assert async_pool2.is_a?(Concurrent::ThreadPoolExecutor) | |
assert_equal 0, async_pool1.min_length | |
assert_equal 10, async_pool1.max_length | |
assert_equal 40, async_pool1.max_queue | |
assert_equal :caller_runs, async_pool1.fallback_policy | |
assert_equal 0, async_pool2.min_length | |
assert_equal 5, async_pool2.max_length | |
assert_equal 20, async_pool2.max_queue | |
assert_equal :caller_runs, async_pool2.fallback_policy | |
assert_equal 2, handler.all_connection_pools.count | |
assert_not_equal async_pool1, async_pool2 | |
ensure | |
clean_up_connection_handler | |
ActiveRecord.async_query_executor = old_value | |
end | |
def test_multi_thread_pool_is_used_only_by_configurations_that_enable_it | |
old_value = ActiveRecord.async_query_executor | |
ActiveRecord.async_query_executor = :multi_thread_pool | |
handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
config_hash1 = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary").configuration_hash | |
new_config1 = config_hash1.merge(min_threads: 0, max_threads: 10) | |
db_config1 = ActiveRecord::DatabaseConfigurations::HashConfig.new("arunit", "primary", new_config1) | |
config_hash2 = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary").configuration_hash | |
new_config2 = config_hash2.merge(min_threads: 0, max_threads: 0) | |
db_config2 = ActiveRecord::DatabaseConfigurations::HashConfig.new("arunit2", "primary", new_config2) | |
pool1 = handler.establish_connection(db_config1) | |
pool2 = handler.establish_connection(db_config2, owner_name: ARUnit2Model) | |
async_pool1 = pool1.instance_variable_get(:@async_executor) | |
async_pool2 = pool2.instance_variable_get(:@async_executor) | |
assert async_pool1.is_a?(Concurrent::ThreadPoolExecutor) | |
assert_nil async_pool2 | |
assert_equal 0, async_pool1.min_length | |
assert_equal 10, async_pool1.max_length | |
assert_equal 40, async_pool1.max_queue | |
assert_equal :caller_runs, async_pool1.fallback_policy | |
assert_equal 2, handler.all_connection_pools.count | |
assert_not_equal async_pool1, async_pool2 | |
ensure | |
clean_up_connection_handler | |
ActiveRecord.async_query_executor = old_value | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
class AsynchronousQueriesTracker # :nodoc: | |
module NullSession # :nodoc: | |
class << self | |
def active? | |
true | |
end | |
def finalize | |
end | |
end | |
end | |
class Session # :nodoc: | |
def initialize | |
@active = true | |
end | |
def active? | |
@active | |
end | |
def finalize | |
@active = false | |
end | |
end | |
class << self | |
def install_executor_hooks(executor = ActiveSupport::Executor) | |
executor.register_hook(self) | |
end | |
def run | |
ActiveRecord::Base.asynchronous_queries_tracker.start_session | |
end | |
def complete(asynchronous_queries_tracker) | |
asynchronous_queries_tracker.finalize_session | |
end | |
end | |
attr_reader :current_session | |
def initialize | |
@current_session = NullSession | |
end | |
def start_session | |
@current_session = Session.new | |
self | |
end | |
def finalize_session | |
@current_session.finalize | |
@current_session = NullSession | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "fileutils" | |
class File | |
# Write to a file atomically. Useful for situations where you don't | |
# want other processes or threads to see half-written files. | |
# | |
# File.atomic_write('important.file') do |file| | |
# file.write('hello') | |
# end | |
# | |
# This method needs to create a temporary file. By default it will create it | |
# in the same directory as the destination file. If you don't like this | |
# behavior you can provide a different directory but it must be on the | |
# same physical filesystem as the file you're trying to write. | |
# | |
# File.atomic_write('/data/something.important', '/data/tmp') do |file| | |
# file.write('hello') | |
# end | |
def self.atomic_write(file_name, temp_dir = dirname(file_name)) | |
require "tempfile" unless defined?(Tempfile) | |
Tempfile.open(".#{basename(file_name)}", temp_dir) do |temp_file| | |
temp_file.binmode | |
return_val = yield temp_file | |
temp_file.close | |
old_stat = if exist?(file_name) | |
# Get original file permissions | |
stat(file_name) | |
else | |
# If not possible, probe which are the default permissions in the | |
# destination directory. | |
probe_stat_in(dirname(file_name)) | |
end | |
if old_stat | |
# Set correct permissions on new file | |
begin | |
chown(old_stat.uid, old_stat.gid, temp_file.path) | |
# This operation will affect filesystem ACL's | |
chmod(old_stat.mode, temp_file.path) | |
rescue Errno::EPERM, Errno::EACCES | |
# Changing file ownership failed, moving on. | |
end | |
end | |
# Overwrite original file with temp file | |
rename(temp_file.path, file_name) | |
return_val | |
end | |
end | |
# Private utility method. | |
def self.probe_stat_in(dir) # :nodoc: | |
basename = [ | |
".permissions_check", | |
Thread.current.object_id, | |
Process.pid, | |
rand(1000000) | |
].join(".") | |
file_name = join(dir, basename) | |
FileUtils.touch(file_name) | |
stat(file_name) | |
rescue Errno::ENOENT | |
file_name = nil | |
ensure | |
FileUtils.rm_f(file_name) if file_name | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/module/delegation" | |
module ActiveStorage | |
# Abstract base class for the concrete ActiveStorage::Attached::One and ActiveStorage::Attached::Many | |
# classes that both provide proxy access to the blob association for a record. | |
class Attached | |
attr_reader :name, :record | |
def initialize(name, record) | |
@name, @record = name, record | |
end | |
private | |
def change | |
record.attachment_changes[name] | |
end | |
end | |
end | |
require "active_storage/attached/model" | |
require "active_storage/attached/one" | |
require "active_storage/attached/many" | |
require "active_storage/attached/changes" |
# frozen_string_literal: true | |
require "active_support/core_ext/module/delegation" | |
# Attachments associate records with blobs. Usually that's a one record-many blobs relationship, | |
# but it is possible to associate many different records with the same blob. A foreign-key constraint | |
# on the attachments table prevents blobs from being purged if they’re still attached to any records. | |
# | |
# Attachments also have access to all methods from {ActiveStorage::Blob}[rdoc-ref:ActiveStorage::Blob]. | |
# | |
# If you wish to preload attachments or blobs, you can use these scopes: | |
# | |
# # preloads attachments, their corresponding blobs, and variant records (if using `ActiveStorage.track_variants`) | |
# User.all.with_attached_avatars | |
# | |
# # preloads blobs and variant records (if using `ActiveStorage.track_variants`) | |
# User.first.avatars.with_all_variant_records | |
class ActiveStorage::Attachment < ActiveStorage::Record | |
self.table_name = "active_storage_attachments" | |
belongs_to :record, polymorphic: true, touch: true | |
belongs_to :blob, class_name: "ActiveStorage::Blob", autosave: true | |
delegate_missing_to :blob | |
delegate :signed_id, to: :blob | |
after_create_commit :mirror_blob_later, :analyze_blob_later | |
after_destroy_commit :purge_dependent_blob_later | |
scope :with_all_variant_records, -> { includes(blob: :variant_records) } | |
# Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge]. | |
def purge | |
transaction do | |
delete | |
record.touch if record&.persisted? | |
end | |
blob&.purge | |
end | |
# Deletes the attachment and {enqueues a background job}[rdoc-ref:ActiveStorage::Blob#purge_later] to purge the blob. | |
def purge_later | |
transaction do | |
delete | |
record.touch if record&.persisted? | |
end | |
blob&.purge_later | |
end | |
# Returns an ActiveStorage::Variant or ActiveStorage::VariantWithRecord | |
# instance for the attachment with the set of +transformations+ provided. | |
# See ActiveStorage::Blob::Representable#variant for more information. | |
# | |
# Raises an +ArgumentError+ if +transformations+ is a +Symbol+ which is an | |
# unknown pre-defined variant of the attachment. | |
def variant(transformations) | |
case transformations | |
when Symbol | |
variant_name = transformations | |
transformations = variants.fetch(variant_name) do | |
record_model_name = record.to_model.model_name.name | |
raise ArgumentError, "Cannot find variant :#{variant_name} for #{record_model_name}##{name}" | |
end | |
end | |
blob.variant(transformations) | |
end | |
private | |
def analyze_blob_later | |
blob.analyze_later unless blob.analyzed? | |
end | |
def mirror_blob_later | |
blob.mirror_later | |
end | |
def purge_dependent_blob_later | |
blob&.purge_later if dependent == :purge_later | |
end | |
def dependent | |
record.attachment_reflections[name]&.options&.fetch(:dependent, nil) | |
end | |
def variants | |
record.attachment_reflections[name]&.variants | |
end | |
end | |
ActiveSupport.run_load_hooks :active_storage_attachment, ActiveStorage::Attachment |
# frozen_string_literal: true | |
require "test_helper" | |
require "database/setup" | |
require "active_support/testing/method_call_assertions" | |
class ActiveStorage::AttachmentTest < ActiveSupport::TestCase | |
include ActiveJob::TestHelper | |
setup do | |
@user = User.create!(name: "Josh") | |
end | |
teardown { ActiveStorage::Blob.all.each(&:delete) } | |
test "analyzing a directly-uploaded blob after attaching it" do | |
blob = directly_upload_file_blob(filename: "racecar.jpg") | |
assert_not blob.analyzed? | |
perform_enqueued_jobs do | |
@user.highlights.attach(blob) | |
end | |
assert blob.reload.analyzed? | |
assert_equal 4104, blob.metadata[:width] | |
assert_equal 2736, blob.metadata[:height] | |
end | |
test "attaching a un-analyzable blob" do | |
blob = create_blob(filename: "blank.txt") | |
assert_not_predicate blob, :analyzed? | |
assert_no_enqueued_jobs do | |
@user.highlights.attach(blob) | |
end | |
assert_predicate blob.reload, :analyzed? | |
end | |
test "mirroring a directly-uploaded blob after attaching it" do | |
with_service("mirror") do | |
blob = directly_upload_file_blob | |
assert_not ActiveStorage::Blob.service.mirrors.second.exist?(blob.key) | |
perform_enqueued_jobs do | |
@user.highlights.attach(blob) | |
end | |
assert ActiveStorage::Blob.service.mirrors.second.exist?(blob.key) | |
end | |
end | |
test "directly-uploaded blob identification for one attached occurs before validation" do | |
blob = directly_upload_file_blob(filename: "racecar.jpg", content_type: "application/octet-stream") | |
assert_blob_identified_before_owner_validated(@user, blob, "image/jpeg") do | |
@user.avatar.attach(blob) | |
end | |
end | |
test "directly-uploaded blob identification for many attached occurs before validation" do | |
blob = directly_upload_file_blob(filename: "racecar.jpg", content_type: "application/octet-stream") | |
assert_blob_identified_before_owner_validated(@user, blob, "image/jpeg") do | |
@user.highlights.attach(blob) | |
end | |
end | |
test "directly-uploaded blob identification for one attached occurs outside transaction" do | |
blob = directly_upload_file_blob(filename: "racecar.jpg") | |
assert_blob_identified_outside_transaction(blob) do | |
@user.avatar.attach(blob) | |
end | |
end | |
test "directly-uploaded blob identification for many attached occurs outside transaction" do | |
blob = directly_upload_file_blob(filename: "racecar.jpg") | |
assert_blob_identified_outside_transaction(blob) do | |
@user.highlights.attach(blob) | |
end | |
end | |
test "getting a signed blob ID from an attachment" do | |
blob = create_blob | |
@user.avatar.attach(blob) | |
signed_id = @user.avatar.signed_id | |
assert_equal blob, ActiveStorage::Blob.find_signed!(signed_id) | |
assert_equal blob, ActiveStorage::Blob.find_signed(signed_id) | |
end | |
test "getting a signed blob ID from an attachment with a custom purpose" do | |
blob = create_blob | |
@user.avatar.attach(blob) | |
signed_id = @user.avatar.signed_id(purpose: :custom_purpose) | |
assert_equal blob, ActiveStorage::Blob.find_signed!(signed_id, purpose: :custom_purpose) | |
end | |
test "getting a signed blob ID from an attachment with a expires_in" do | |
blob = create_blob | |
@user.avatar.attach(blob) | |
signed_id = @user.avatar.signed_id(expires_in: 1.minute) | |
assert_equal blob, ActiveStorage::Blob.find_signed!(signed_id) | |
end | |
test "fail to find blob within expiration date" do | |
blob = create_blob | |
@user.avatar.attach(blob) | |
signed_id = @user.avatar.signed_id(expires_in: 1.minute) | |
travel 2.minutes | |
assert_nil ActiveStorage::Blob.find_signed(signed_id) | |
end | |
test "signed blob ID backwards compatibility" do | |
blob = create_blob | |
@user.avatar.attach(blob) | |
signed_id_generated_old_way = ActiveStorage.verifier.generate(@user.avatar.blob.id, purpose: :blob_id) | |
assert_equal blob, ActiveStorage::Blob.find_signed!(signed_id_generated_old_way) | |
end | |
test "attaching with strict_loading and getting a signed blob ID from an attachment" do | |
blob = create_blob | |
@user.strict_loading!(true) | |
@user.avatar.attach(blob) | |
signed_id = @user.avatar.signed_id | |
assert_equal blob, ActiveStorage::Blob.find_signed(signed_id) | |
end | |
test "can destroy attachment without existing relation" do | |
blob = create_blob | |
@user.highlights.attach(blob) | |
attachment = @user.highlights.find_by(blob_id: blob.id) | |
attachment.update_attribute(:name, "old_highlights") | |
assert_nothing_raised { attachment.destroy } | |
end | |
private | |
def assert_blob_identified_before_owner_validated(owner, blob, content_type) | |
validated_content_type = nil | |
owner.class.validate do | |
validated_content_type ||= blob.content_type | |
end | |
yield | |
assert_equal content_type, validated_content_type | |
assert_equal content_type, blob.reload.content_type | |
end | |
def assert_blob_identified_outside_transaction(blob, &block) | |
baseline_transaction_depth = ActiveRecord::Base.connection.open_transactions | |
max_transaction_depth = -1 | |
track_transaction_depth = ->(*) do | |
max_transaction_depth = [ActiveRecord::Base.connection.open_transactions, max_transaction_depth].max | |
end | |
blob.stub(:identify_without_saving, track_transaction_depth, &block) | |
assert_equal 0, (max_transaction_depth - baseline_transaction_depth) | |
end | |
end |
# frozen_string_literal: true | |
class Module | |
# Declares an attribute reader backed by an internally-named instance variable. | |
def attr_internal_reader(*attrs) | |
attrs.each { |attr_name| attr_internal_define(attr_name, :reader) } | |
end | |
# Declares an attribute writer backed by an internally-named instance variable. | |
def attr_internal_writer(*attrs) | |
attrs.each { |attr_name| attr_internal_define(attr_name, :writer) } | |
end | |
# Declares an attribute reader and writer backed by an internally-named instance | |
# variable. | |
def attr_internal_accessor(*attrs) | |
attr_internal_reader(*attrs) | |
attr_internal_writer(*attrs) | |
end | |
alias_method :attr_internal, :attr_internal_accessor | |
class << self; attr_accessor :attr_internal_naming_format end | |
self.attr_internal_naming_format = "@_%s" | |
private | |
def attr_internal_ivar_name(attr) | |
Module.attr_internal_naming_format % attr | |
end | |
def attr_internal_define(attr_name, type) | |
internal_name = attr_internal_ivar_name(attr_name).delete_prefix("@") | |
# use native attr_* methods as they are faster on some Ruby implementations | |
public_send("attr_#{type}", internal_name) | |
attr_name, internal_name = "#{attr_name}=", "#{internal_name}=" if type == :writer | |
alias_method attr_name, internal_name | |
remove_method internal_name | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/module/attr_internal" | |
class AttrInternalTest < ActiveSupport::TestCase | |
def setup | |
@target = Class.new | |
@instance = @target.new | |
end | |
def test_reader | |
assert_nothing_raised { @target.attr_internal_reader :foo } | |
assert_not @instance.instance_variable_defined?("@_foo") | |
assert_raise(NoMethodError) { @instance.foo = 1 } | |
@instance.instance_variable_set("@_foo", 1) | |
assert_nothing_raised { assert_equal 1, @instance.foo } | |
end | |
def test_writer | |
assert_nothing_raised { @target.attr_internal_writer :foo } | |
assert_not @instance.instance_variable_defined?("@_foo") | |
assert_nothing_raised { assert_equal 1, @instance.foo = 1 } | |
assert_equal 1, @instance.instance_variable_get("@_foo") | |
assert_raise(NoMethodError) { @instance.foo } | |
end | |
def test_accessor | |
assert_nothing_raised { @target.attr_internal :foo } | |
assert_not @instance.instance_variable_defined?("@_foo") | |
assert_nothing_raised { assert_equal 1, @instance.foo = 1 } | |
assert_equal 1, @instance.instance_variable_get("@_foo") | |
assert_nothing_raised { assert_equal 1, @instance.foo } | |
end | |
def test_naming_format | |
assert_equal "@_%s", Module.attr_internal_naming_format | |
assert_nothing_raised { Module.attr_internal_naming_format = "@abc%sdef" } | |
@target.attr_internal :foo | |
assert_not @instance.instance_variable_defined?("@_foo") | |
assert_not @instance.instance_variable_defined?("@abcfoodef") | |
assert_nothing_raised { @instance.foo = 1 } | |
assert_not @instance.instance_variable_defined?("@_foo") | |
assert @instance.instance_variable_defined?("@abcfoodef") | |
ensure | |
Module.attr_internal_naming_format = "@_%s" | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/module/redefine_method" | |
class Class | |
# Declare a class-level attribute whose value is inheritable by subclasses. | |
# Subclasses can change their own value and it will not impact parent class. | |
# | |
# ==== Options | |
# | |
# * <tt>:instance_reader</tt> - Sets the instance reader method (defaults to true). | |
# * <tt>:instance_writer</tt> - Sets the instance writer method (defaults to true). | |
# * <tt>:instance_accessor</tt> - Sets both instance methods (defaults to true). | |
# * <tt>:instance_predicate</tt> - Sets a predicate method (defaults to true). | |
# * <tt>:default</tt> - Sets a default value for the attribute (defaults to nil). | |
# | |
# ==== Examples | |
# | |
# class Base | |
# class_attribute :setting | |
# end | |
# | |
# class Subclass < Base | |
# end | |
# | |
# Base.setting = true | |
# Subclass.setting # => true | |
# Subclass.setting = false | |
# Subclass.setting # => false | |
# Base.setting # => true | |
# | |
# In the above case as long as Subclass does not assign a value to setting | |
# by performing <tt>Subclass.setting = _something_</tt>, <tt>Subclass.setting</tt> | |
# would read value assigned to parent class. Once Subclass assigns a value then | |
# the value assigned by Subclass would be returned. | |
# | |
# This matches normal Ruby method inheritance: think of writing an attribute | |
# on a subclass as overriding the reader method. However, you need to be aware | |
# when using +class_attribute+ with mutable structures as +Array+ or +Hash+. | |
# In such cases, you don't want to do changes in place. Instead use setters: | |
# | |
# Base.setting = [] | |
# Base.setting # => [] | |
# Subclass.setting # => [] | |
# | |
# # Appending in child changes both parent and child because it is the same object: | |
# Subclass.setting << :foo | |
# Base.setting # => [:foo] | |
# Subclass.setting # => [:foo] | |
# | |
# # Use setters to not propagate changes: | |
# Base.setting = [] | |
# Subclass.setting += [:foo] | |
# Base.setting # => [] | |
# Subclass.setting # => [:foo] | |
# | |
# For convenience, an instance predicate method is defined as well. | |
# To skip it, pass <tt>instance_predicate: false</tt>. | |
# | |
# Subclass.setting? # => false | |
# | |
# Instances may overwrite the class value in the same way: | |
# | |
# Base.setting = true | |
# object = Base.new | |
# object.setting # => true | |
# object.setting = false | |
# object.setting # => false | |
# Base.setting # => true | |
# | |
# To opt out of the instance reader method, pass <tt>instance_reader: false</tt>. | |
# | |
# object.setting # => NoMethodError | |
# object.setting? # => NoMethodError | |
# | |
# To opt out of the instance writer method, pass <tt>instance_writer: false</tt>. | |
# | |
# object.setting = false # => NoMethodError | |
# | |
# To opt out of both instance methods, pass <tt>instance_accessor: false</tt>. | |
# | |
# To set a default value for the attribute, pass <tt>default:</tt>, like so: | |
# | |
# class_attribute :settings, default: {} | |
def class_attribute(*attrs, instance_accessor: true, | |
instance_reader: instance_accessor, instance_writer: instance_accessor, instance_predicate: true, default: nil) | |
class_methods, methods = [], [] | |
attrs.each do |name| | |
unless name.is_a?(Symbol) || name.is_a?(String) | |
raise TypeError, "#{name.inspect} is not a symbol nor a string" | |
end | |
class_methods << <<~RUBY # In case the method exists and is not public | |
silence_redefinition_of_method def #{name} | |
end | |
RUBY | |
methods << <<~RUBY if instance_reader | |
silence_redefinition_of_method def #{name} | |
defined?(@#{name}) ? @#{name} : self.class.#{name} | |
end | |
RUBY | |
class_methods << <<~RUBY | |
silence_redefinition_of_method def #{name}=(value) | |
redefine_method(:#{name}) { value } if singleton_class? | |
redefine_singleton_method(:#{name}) { value } | |
value | |
end | |
RUBY | |
methods << <<~RUBY if instance_writer | |
silence_redefinition_of_method(:#{name}=) | |
attr_writer :#{name} | |
RUBY | |
if instance_predicate | |
class_methods << "silence_redefinition_of_method def #{name}?; !!self.#{name}; end" | |
if instance_reader | |
methods << "silence_redefinition_of_method def #{name}?; !!self.#{name}; end" | |
end | |
end | |
end | |
location = caller_locations(1, 1).first | |
class_eval(["class << self", *class_methods, "end", *methods].join(";").tr("\n", ";"), location.path, location.lineno) | |
attrs.each { |name| public_send("#{name}=", default) } | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/module/attribute_accessors_per_thread" | |
class ModuleAttributeAccessorPerThreadTest < ActiveSupport::TestCase | |
class MyClass | |
thread_mattr_accessor :foo | |
thread_mattr_accessor :bar, instance_writer: false | |
thread_mattr_reader :shaq, instance_reader: false | |
thread_mattr_accessor :camp, instance_accessor: false | |
end | |
class SubMyClass < MyClass | |
end | |
setup do | |
@class = MyClass | |
@subclass = SubMyClass | |
@object = @class.new | |
end | |
def test_is_shared_between_fibers | |
@class.foo = 42 | |
enumerator = Enumerator.new do |yielder| | |
yielder.yield @class.foo | |
end | |
assert_equal 42, enumerator.next | |
end | |
def test_is_not_shared_between_fibers_if_isolation_level_is_fiber | |
previous_level = ActiveSupport::IsolatedExecutionState.isolation_level | |
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber | |
@class.foo = 42 | |
enumerator = Enumerator.new do |yielder| | |
yielder.yield @class.foo | |
end | |
assert_nil enumerator.next | |
ensure | |
ActiveSupport::IsolatedExecutionState.isolation_level = previous_level | |
end | |
def test_can_initialize_with_default_value | |
Thread.new do | |
@class.thread_mattr_accessor :baz, default: "default_value" | |
assert_equal "default_value", @class.baz | |
end.join | |
assert_nil @class.baz | |
end | |
def test_should_use_mattr_default | |
Thread.new do | |
assert_nil @class.foo | |
assert_nil @object.foo | |
end.join | |
end | |
def test_should_set_mattr_value | |
Thread.new do | |
@class.foo = :test | |
assert_equal :test, @class.foo | |
@class.foo = :test2 | |
assert_equal :test2, @class.foo | |
end.join | |
end | |
def test_should_not_create_instance_writer | |
Thread.new do | |
assert_respond_to @class, :foo | |
assert_respond_to @class, :foo= | |
assert_respond_to @object, :bar | |
assert_not_respond_to @object, :bar= | |
end.join | |
end | |
def test_should_not_create_instance_reader | |
Thread.new do | |
assert_respond_to @class, :shaq | |
assert_not_respond_to @object, :shaq | |
end.join | |
end | |
def test_should_not_create_instance_accessors | |
Thread.new do | |
assert_respond_to @class, :camp | |
assert_not_respond_to @object, :camp | |
assert_not_respond_to @object, :camp= | |
end.join | |
end | |
def test_values_should_not_bleed_between_threads | |
threads = [] | |
threads << Thread.new do | |
@class.foo = "things" | |
Thread.pass | |
assert_equal "things", @class.foo | |
end | |
threads << Thread.new do | |
@class.foo = "other things" | |
Thread.pass | |
assert_equal "other things", @class.foo | |
end | |
threads << Thread.new do | |
@class.foo = "really other things" | |
Thread.pass | |
assert_equal "really other things", @class.foo | |
end | |
threads.each(&:join) | |
end | |
def test_should_raise_name_error_if_attribute_name_is_invalid | |
exception = assert_raises NameError do | |
Class.new do | |
thread_cattr_reader "1nvalid" | |
end | |
end | |
assert_match "invalid attribute name: 1nvalid", exception.message | |
exception = assert_raises NameError do | |
Class.new do | |
thread_cattr_writer "1nvalid" | |
end | |
end | |
assert_match "invalid attribute name: 1nvalid", exception.message | |
exception = assert_raises NameError do | |
Class.new do | |
thread_mattr_reader "1valid_part" | |
end | |
end | |
assert_match "invalid attribute name: 1valid_part", exception.message | |
exception = assert_raises NameError do | |
Class.new do | |
thread_mattr_writer "2valid_part" | |
end | |
end | |
assert_match "invalid attribute name: 2valid_part", exception.message | |
end | |
def test_should_return_same_value_by_class_or_instance_accessor | |
@class.foo = "fries" | |
assert_equal @class.foo, @object.foo | |
end | |
def test_should_not_affect_superclass_if_subclass_set_value | |
@class.foo = "super" | |
assert_equal "super", @class.foo | |
assert_nil @subclass.foo | |
@subclass.foo = "sub" | |
assert_equal "super", @class.foo | |
assert_equal "sub", @subclass.foo | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/module/attribute_accessors" | |
class ModuleAttributeAccessorTest < ActiveSupport::TestCase | |
def setup | |
m = @module = Module.new do | |
mattr_accessor :foo | |
mattr_accessor :bar, instance_writer: false | |
mattr_reader :shaq, instance_reader: false | |
mattr_accessor :camp, instance_accessor: false | |
cattr_accessor(:defa) { "default_accessor_value" } | |
cattr_reader(:defr) { "default_reader_value" } | |
cattr_writer(:defw) { "default_writer_value" } | |
cattr_accessor(:deff) { false } | |
cattr_accessor(:quux) { :quux } | |
cattr_accessor :def_accessor, default: "default_accessor_value" | |
cattr_reader :def_reader, default: "default_reader_value" | |
cattr_writer :def_writer, default: "default_writer_value" | |
cattr_accessor :def_false, default: false | |
cattr_accessor(:def_priority, default: false) { :no_priority } | |
end | |
@class = Class.new | |
@class.instance_eval { include m } | |
@object = @class.new | |
end | |
def test_should_use_mattr_default | |
assert_nil @module.foo | |
assert_nil @object.foo | |
end | |
def test_mattr_default_keyword_arguments | |
assert_equal "default_accessor_value", @module.def_accessor | |
assert_equal "default_reader_value", @module.def_reader | |
assert_equal "default_writer_value", @module.class_variable_get(:@@def_writer) | |
end | |
def test_mattr_can_default_to_false | |
assert_equal false, @module.def_false | |
assert_equal false, @module.deff | |
end | |
def test_mattr_default_priority | |
assert_equal false, @module.def_priority | |
end | |
def test_should_set_mattr_value | |
@module.foo = :test | |
assert_equal :test, @object.foo | |
@object.foo = :test2 | |
assert_equal :test2, @module.foo | |
end | |
def test_cattr_accessor_default_value | |
assert_equal :quux, @module.quux | |
assert_equal :quux, @object.quux | |
end | |
def test_should_not_create_instance_writer | |
assert_respond_to @module, :foo | |
assert_respond_to @module, :foo= | |
assert_respond_to @object, :bar | |
assert_not_respond_to @object, :bar= | |
end | |
def test_should_not_create_instance_reader | |
assert_respond_to @module, :shaq | |
assert_not_respond_to @object, :shaq | |
end | |
def test_should_not_create_instance_accessors | |
assert_respond_to @module, :camp | |
assert_not_respond_to @object, :camp | |
assert_not_respond_to @object, :camp= | |
end | |
def test_should_raise_name_error_if_attribute_name_is_invalid | |
exception = assert_raises NameError do | |
Class.new do | |
cattr_reader "1nvalid" | |
end | |
end | |
assert_match "invalid attribute name: 1nvalid", exception.message | |
exception = assert_raises NameError do | |
Class.new do | |
cattr_writer "1nvalid" | |
end | |
end | |
assert_match "invalid attribute name: 1nvalid", exception.message | |
exception = assert_raises NameError do | |
Class.new do | |
mattr_reader "valid_part\ninvalid_part" | |
end | |
end | |
assert_match "invalid attribute name: valid_part\ninvalid_part", exception.message | |
exception = assert_raises NameError do | |
Class.new do | |
mattr_writer "valid_part\ninvalid_part" | |
end | |
end | |
assert_match "invalid attribute name: valid_part\ninvalid_part", exception.message | |
end | |
def test_should_use_default_value_if_block_passed | |
assert_equal "default_accessor_value", @module.defa | |
assert_equal "default_reader_value", @module.defr | |
assert_equal "default_writer_value", @module.class_variable_get("@@defw") | |
end | |
def test_method_invocation_should_not_invoke_the_default_block | |
count = 0 | |
@module.cattr_accessor(:defcount) { count += 1 } | |
assert_equal 1, count | |
assert_no_difference "count" do | |
@module.defcount | |
end | |
end | |
def test_declaring_multiple_attributes_at_once_invokes_the_block_multiple_times | |
count = 0 | |
@module.cattr_accessor(:defn1, :defn2) { count += 1 } | |
assert_equal 1, @module.defn1 | |
assert_equal 2, @module.defn2 | |
end | |
def test_declaring_attributes_on_singleton_errors | |
klass = Class.new | |
ex = assert_raises TypeError do | |
class << klass | |
mattr_accessor :my_attr | |
end | |
end | |
assert_equal "module attributes should be defined directly on class, not singleton", ex.message | |
assert_not_includes Module.class_variables, :@@my_attr | |
end | |
end |
# frozen_string_literal: true | |
# == Attribute Accessors | |
# | |
# Extends the module object with class/module and instance accessors for | |
# class/module attributes, just like the native attr* accessors for instance | |
# attributes. | |
class Module | |
# Defines a class attribute and creates a class and instance reader methods. | |
# The underlying class variable is set to +nil+, if it is not previously | |
# defined. All class and instance methods created will be public, even if | |
# this method is called with a private or protected access modifier. | |
# | |
# module HairColors | |
# mattr_reader :hair_colors | |
# end | |
# | |
# HairColors.hair_colors # => nil | |
# HairColors.class_variable_set("@@hair_colors", [:brown, :black]) | |
# HairColors.hair_colors # => [:brown, :black] | |
# | |
# The attribute name must be a valid method name in Ruby. | |
# | |
# module Foo | |
# mattr_reader :"1_Badname" | |
# end | |
# # => NameError: invalid attribute name: 1_Badname | |
# | |
# To omit the instance reader method, pass | |
# <tt>instance_reader: false</tt> or <tt>instance_accessor: false</tt>. | |
# | |
# module HairColors | |
# mattr_reader :hair_colors, instance_reader: false | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.new.hair_colors # => NoMethodError | |
# | |
# You can set a default value for the attribute. | |
# | |
# module HairColors | |
# mattr_reader :hair_colors, default: [:brown, :black, :blonde, :red] | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.new.hair_colors # => [:brown, :black, :blonde, :red] | |
def mattr_reader(*syms, instance_reader: true, instance_accessor: true, default: nil, location: nil) | |
raise TypeError, "module attributes should be defined directly on class, not singleton" if singleton_class? | |
location ||= caller_locations(1, 1).first | |
definition = [] | |
syms.each do |sym| | |
raise NameError.new("invalid attribute name: #{sym}") unless /\A[_A-Za-z]\w*\z/.match?(sym) | |
definition << "def self.#{sym}; @@#{sym}; end" | |
if instance_reader && instance_accessor | |
definition << "def #{sym}; @@#{sym}; end" | |
end | |
sym_default_value = (block_given? && default.nil?) ? yield : default | |
class_variable_set("@@#{sym}", sym_default_value) unless sym_default_value.nil? && class_variable_defined?("@@#{sym}") | |
end | |
module_eval(definition.join(";"), location.path, location.lineno) | |
end | |
alias :cattr_reader :mattr_reader | |
# Defines a class attribute and creates a class and instance writer methods to | |
# allow assignment to the attribute. All class and instance methods created | |
# will be public, even if this method is called with a private or protected | |
# access modifier. | |
# | |
# module HairColors | |
# mattr_writer :hair_colors | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# HairColors.hair_colors = [:brown, :black] | |
# Person.class_variable_get("@@hair_colors") # => [:brown, :black] | |
# Person.new.hair_colors = [:blonde, :red] | |
# HairColors.class_variable_get("@@hair_colors") # => [:blonde, :red] | |
# | |
# To omit the instance writer method, pass | |
# <tt>instance_writer: false</tt> or <tt>instance_accessor: false</tt>. | |
# | |
# module HairColors | |
# mattr_writer :hair_colors, instance_writer: false | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.new.hair_colors = [:blonde, :red] # => NoMethodError | |
# | |
# You can set a default value for the attribute. | |
# | |
# module HairColors | |
# mattr_writer :hair_colors, default: [:brown, :black, :blonde, :red] | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.class_variable_get("@@hair_colors") # => [:brown, :black, :blonde, :red] | |
def mattr_writer(*syms, instance_writer: true, instance_accessor: true, default: nil, location: nil) | |
raise TypeError, "module attributes should be defined directly on class, not singleton" if singleton_class? | |
location ||= caller_locations(1, 1).first | |
definition = [] | |
syms.each do |sym| | |
raise NameError.new("invalid attribute name: #{sym}") unless /\A[_A-Za-z]\w*\z/.match?(sym) | |
definition << "def self.#{sym}=(val); @@#{sym} = val; end" | |
if instance_writer && instance_accessor | |
definition << "def #{sym}=(val); @@#{sym} = val; end" | |
end | |
sym_default_value = (block_given? && default.nil?) ? yield : default | |
class_variable_set("@@#{sym}", sym_default_value) unless sym_default_value.nil? && class_variable_defined?("@@#{sym}") | |
end | |
module_eval(definition.join(";"), location.path, location.lineno) | |
end | |
alias :cattr_writer :mattr_writer | |
# Defines both class and instance accessors for class attributes. | |
# All class and instance methods created will be public, even if | |
# this method is called with a private or protected access modifier. | |
# | |
# module HairColors | |
# mattr_accessor :hair_colors | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# HairColors.hair_colors = [:brown, :black, :blonde, :red] | |
# HairColors.hair_colors # => [:brown, :black, :blonde, :red] | |
# Person.new.hair_colors # => [:brown, :black, :blonde, :red] | |
# | |
# If a subclass changes the value then that would also change the value for | |
# parent class. Similarly if parent class changes the value then that would | |
# change the value of subclasses too. | |
# | |
# class Citizen < Person | |
# end | |
# | |
# Citizen.new.hair_colors << :blue | |
# Person.new.hair_colors # => [:brown, :black, :blonde, :red, :blue] | |
# | |
# To omit the instance writer method, pass <tt>instance_writer: false</tt>. | |
# To omit the instance reader method, pass <tt>instance_reader: false</tt>. | |
# | |
# module HairColors | |
# mattr_accessor :hair_colors, instance_writer: false, instance_reader: false | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.new.hair_colors = [:brown] # => NoMethodError | |
# Person.new.hair_colors # => NoMethodError | |
# | |
# Or pass <tt>instance_accessor: false</tt>, to omit both instance methods. | |
# | |
# module HairColors | |
# mattr_accessor :hair_colors, instance_accessor: false | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.new.hair_colors = [:brown] # => NoMethodError | |
# Person.new.hair_colors # => NoMethodError | |
# | |
# You can set a default value for the attribute. | |
# | |
# module HairColors | |
# mattr_accessor :hair_colors, default: [:brown, :black, :blonde, :red] | |
# end | |
# | |
# class Person | |
# include HairColors | |
# end | |
# | |
# Person.class_variable_get("@@hair_colors") # => [:brown, :black, :blonde, :red] | |
def mattr_accessor(*syms, instance_reader: true, instance_writer: true, instance_accessor: true, default: nil, &blk) | |
location = caller_locations(1, 1).first | |
mattr_reader(*syms, instance_reader: instance_reader, instance_accessor: instance_accessor, default: default, location: location, &blk) | |
mattr_writer(*syms, instance_writer: instance_writer, instance_accessor: instance_accessor, default: default, location: location) | |
end | |
alias :cattr_accessor :mattr_accessor | |
end |
# frozen_string_literal: true | |
# == Attribute Accessors per Thread | |
# | |
# Extends the module object with class/module and instance accessors for | |
# class/module attributes, just like the native attr* accessors for instance | |
# attributes, but does so on a per-thread basis. | |
# | |
# So the values are scoped within the Thread.current space under the class name | |
# of the module. | |
# | |
# Note that it can also be scoped per-fiber if +Rails.application.config.active_support.isolation_level+ | |
# is set to +:fiber+. | |
class Module | |
# Defines a per-thread class attribute and creates class and instance reader methods. | |
# The underlying per-thread class variable is set to +nil+, if it is not previously defined. | |
# | |
# module Current | |
# thread_mattr_reader :user | |
# end | |
# | |
# Current.user = "DHH" | |
# Current.user # => "DHH" | |
# Thread.new { Current.user }.value # => nil | |
# | |
# The attribute name must be a valid method name in Ruby. | |
# | |
# module Foo | |
# thread_mattr_reader :"1_Badname" | |
# end | |
# # => NameError: invalid attribute name: 1_Badname | |
# | |
# To omit the instance reader method, pass | |
# <tt>instance_reader: false</tt> or <tt>instance_accessor: false</tt>. | |
# | |
# class Current | |
# thread_mattr_reader :user, instance_reader: false | |
# end | |
# | |
# Current.new.user # => NoMethodError | |
def thread_mattr_reader(*syms, instance_reader: true, instance_accessor: true, default: nil) # :nodoc: | |
syms.each do |sym| | |
raise NameError.new("invalid attribute name: #{sym}") unless /^[_A-Za-z]\w*$/.match?(sym) | |
# The following generated method concatenates `name` because we want it | |
# to work with inheritance via polymorphism. | |
class_eval(<<-EOS, __FILE__, __LINE__ + 1) | |
def self.#{sym} | |
@__thread_mattr_#{sym} ||= "attr_\#{name}_#{sym}" | |
::ActiveSupport::IsolatedExecutionState[@__thread_mattr_#{sym}] | |
end | |
EOS | |
if instance_reader && instance_accessor | |
class_eval(<<-EOS, __FILE__, __LINE__ + 1) | |
def #{sym} | |
self.class.#{sym} | |
end | |
EOS | |
end | |
::ActiveSupport::IsolatedExecutionState["attr_#{name}_#{sym}"] = default unless default.nil? | |
end | |
end | |
alias :thread_cattr_reader :thread_mattr_reader | |
# Defines a per-thread class attribute and creates a class and instance writer methods to | |
# allow assignment to the attribute. | |
# | |
# module Current | |
# thread_mattr_writer :user | |
# end | |
# | |
# Current.user = "DHH" | |
# Thread.current[:attr_Current_user] # => "DHH" | |
# | |
# To omit the instance writer method, pass | |
# <tt>instance_writer: false</tt> or <tt>instance_accessor: false</tt>. | |
# | |
# class Current | |
# thread_mattr_writer :user, instance_writer: false | |
# end | |
# | |
# Current.new.user = "DHH" # => NoMethodError | |
def thread_mattr_writer(*syms, instance_writer: true, instance_accessor: true, default: nil) # :nodoc: | |
syms.each do |sym| | |
raise NameError.new("invalid attribute name: #{sym}") unless /^[_A-Za-z]\w*$/.match?(sym) | |
# The following generated method concatenates `name` because we want it | |
# to work with inheritance via polymorphism. | |
class_eval(<<-EOS, __FILE__, __LINE__ + 1) | |
def self.#{sym}=(obj) | |
@__thread_mattr_#{sym} ||= "attr_\#{name}_#{sym}" | |
::ActiveSupport::IsolatedExecutionState[@__thread_mattr_#{sym}] = obj | |
end | |
EOS | |
if instance_writer && instance_accessor | |
class_eval(<<-EOS, __FILE__, __LINE__ + 1) | |
def #{sym}=(obj) | |
self.class.#{sym} = obj | |
end | |
EOS | |
end | |
public_send("#{sym}=", default) unless default.nil? | |
end | |
end | |
alias :thread_cattr_writer :thread_mattr_writer | |
# Defines both class and instance accessors for class attributes. | |
# | |
# class Account | |
# thread_mattr_accessor :user | |
# end | |
# | |
# Account.user = "DHH" | |
# Account.user # => "DHH" | |
# Account.new.user # => "DHH" | |
# | |
# Unlike +mattr_accessor+, values are *not* shared with subclasses or parent classes. | |
# If a subclass changes the value, the parent class' value is not changed. | |
# If the parent class changes the value, the value of subclasses is not changed. | |
# | |
# class Customer < Account | |
# end | |
# | |
# Account.user # => "DHH" | |
# Customer.user # => nil | |
# Customer.user = "Rafael" | |
# Customer.user # => "Rafael" | |
# Account.user # => "DHH" | |
# | |
# To omit the instance writer method, pass <tt>instance_writer: false</tt>. | |
# To omit the instance reader method, pass <tt>instance_reader: false</tt>. | |
# | |
# class Current | |
# thread_mattr_accessor :user, instance_writer: false, instance_reader: false | |
# end | |
# | |
# Current.new.user = "DHH" # => NoMethodError | |
# Current.new.user # => NoMethodError | |
# | |
# Or pass <tt>instance_accessor: false</tt>, to omit both instance methods. | |
# | |
# class Current | |
# thread_mattr_accessor :user, instance_accessor: false | |
# end | |
# | |
# Current.new.user = "DHH" # => NoMethodError | |
# Current.new.user # => NoMethodError | |
def thread_mattr_accessor(*syms, instance_reader: true, instance_writer: true, instance_accessor: true, default: nil) | |
thread_mattr_reader(*syms, instance_reader: instance_reader, instance_accessor: instance_accessor, default: default) | |
thread_mattr_writer(*syms, instance_writer: instance_writer, instance_accessor: instance_accessor) | |
end | |
alias :thread_cattr_accessor :thread_mattr_accessor | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/module/aliasing" | |
module AttributeAliasing | |
class Content | |
attr_accessor :title, :Data | |
def initialize | |
@title, @Data = nil, nil | |
end | |
def title? | |
!title.nil? | |
end | |
def Data? | |
!self.Data.nil? | |
end | |
end | |
class Email < Content | |
alias_attribute :subject, :title | |
alias_attribute :body, :Data | |
end | |
end | |
class AttributeAliasingTest < ActiveSupport::TestCase | |
def test_attribute_alias | |
e = AttributeAliasing::Email.new | |
assert_not_predicate e, :subject? | |
e.title = "Upgrade computer" | |
assert_equal "Upgrade computer", e.subject | |
assert_predicate e, :subject? | |
e.subject = "We got a long way to go" | |
assert_equal "We got a long way to go", e.title | |
assert_predicate e, :title? | |
end | |
def test_aliasing_to_uppercase_attributes | |
# Although it's very un-Ruby, some people's AR-mapped tables have | |
# upper-case attributes, and when people want to alias those names | |
# to more sensible ones, everything goes *foof*. | |
e = AttributeAliasing::Email.new | |
assert_not_predicate e, :body? | |
assert_not_predicate e, :Data? | |
e.body = "No, really, this is not a joke." | |
assert_equal "No, really, this is not a joke.", e.Data | |
assert_predicate e, :Data? | |
e.Data = "Uppercased methods are the suck" | |
assert_equal "Uppercased methods are the suck", e.body | |
assert_predicate e, :body? | |
end | |
end |
# frozen_string_literal: true | |
require "active_model/forbidden_attributes_protection" | |
module ActiveRecord | |
module AttributeAssignment | |
include ActiveModel::AttributeAssignment | |
private | |
def _assign_attributes(attributes) | |
multi_parameter_attributes = nested_parameter_attributes = nil | |
attributes.each do |k, v| | |
key = k.to_s | |
if key.include?("(") | |
(multi_parameter_attributes ||= {})[key] = v | |
elsif v.is_a?(Hash) | |
(nested_parameter_attributes ||= {})[key] = v | |
else | |
_assign_attribute(key, v) | |
end | |
end | |
assign_nested_parameter_attributes(nested_parameter_attributes) if nested_parameter_attributes | |
assign_multiparameter_attributes(multi_parameter_attributes) if multi_parameter_attributes | |
end | |
# Assign any deferred nested attributes after the base attributes have been set. | |
def assign_nested_parameter_attributes(pairs) | |
pairs.each { |k, v| _assign_attribute(k, v) } | |
end | |
# Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done | |
# by calling new on the column type or aggregation type (through composed_of) object with these parameters. | |
# So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate | |
# written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the | |
# parentheses to have the parameters typecasted before they're used in the constructor. Use i for Integer and | |
# f for Float. If all the values for a given attribute are empty, the attribute will be set to +nil+. | |
def assign_multiparameter_attributes(pairs) | |
execute_callstack_for_multiparameter_attributes( | |
extract_callstack_for_multiparameter_attributes(pairs) | |
) | |
end | |
def execute_callstack_for_multiparameter_attributes(callstack) | |
errors = [] | |
callstack.each do |name, values_with_empty_parameters| | |
if values_with_empty_parameters.each_value.all?(NilClass) | |
values = nil | |
else | |
values = values_with_empty_parameters | |
end | |
send("#{name}=", values) | |
rescue => ex | |
errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) | |
end | |
unless errors.empty? | |
error_descriptions = errors.map(&:message).join(",") | |
raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]" | |
end | |
end | |
def extract_callstack_for_multiparameter_attributes(pairs) | |
attributes = {} | |
pairs.each do |(multiparameter_name, value)| | |
attribute_name = multiparameter_name.split("(").first | |
attributes[attribute_name] ||= {} | |
parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value) | |
attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value | |
end | |
attributes | |
end | |
def type_cast_attribute_value(multiparameter_name, value) | |
multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value | |
end | |
def find_parameter_position(multiparameter_name) | |
multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "active_support/core_ext/hash/indifferent_access" | |
require "active_support/hash_with_indifferent_access" | |
class AttributeAssignmentTest < ActiveModel::TestCase | |
class Model | |
include ActiveModel::AttributeAssignment | |
attr_accessor :name, :description | |
def initialize(attributes = {}) | |
assign_attributes(attributes) | |
end | |
def broken_attribute=(value) | |
raise ErrorFromAttributeWriter | |
end | |
private | |
attr_writer :metadata | |
end | |
class ErrorFromAttributeWriter < StandardError | |
end | |
class ProtectedParams | |
attr_accessor :permitted | |
alias :permitted? :permitted | |
delegate :keys, :key?, :has_key?, :empty?, to: :@parameters | |
def initialize(attributes) | |
@parameters = attributes.with_indifferent_access | |
@permitted = false | |
end | |
def permit! | |
@permitted = true | |
self | |
end | |
def [](key) | |
@parameters[key] | |
end | |
def to_h | |
@parameters | |
end | |
def each_pair(&block) | |
@parameters.each_pair(&block) | |
end | |
def dup | |
super.tap do |duplicate| | |
duplicate.instance_variable_set :@permitted, permitted? | |
end | |
end | |
end | |
test "simple assignment" do | |
model = Model.new | |
model.assign_attributes(name: "hello", description: "world") | |
assert_equal "hello", model.name | |
assert_equal "world", model.description | |
end | |
test "simple assignment alias" do | |
model = Model.new | |
model.attributes = { name: "hello", description: "world" } | |
assert_equal "hello", model.name | |
assert_equal "world", model.description | |
end | |
test "assign non-existing attribute" do | |
model = Model.new | |
error = assert_raises(ActiveModel::UnknownAttributeError) do | |
model.assign_attributes(hz: 1) | |
end | |
assert_equal model, error.record | |
assert_equal "hz", error.attribute | |
end | |
test "assign private attribute" do | |
model = Model.new | |
assert_raises(ActiveModel::UnknownAttributeError) do | |
model.assign_attributes(metadata: { a: 1 }) | |
end | |
end | |
test "does not swallow errors raised in an attribute writer" do | |
assert_raises(ErrorFromAttributeWriter) do | |
Model.new(broken_attribute: 1) | |
end | |
end | |
test "an ArgumentError is raised if a non-hash-like object is passed" do | |
err = assert_raises(ArgumentError) do | |
Model.new(1) | |
end | |
assert_equal("When assigning attributes, you must pass a hash as an argument, Integer passed.", err.message) | |
end | |
test "forbidden attributes cannot be used for mass assignment" do | |
params = ProtectedParams.new(name: "Guille", description: "m") | |
assert_raises(ActiveModel::ForbiddenAttributesError) do | |
Model.new(params) | |
end | |
end | |
test "permitted attributes can be used for mass assignment" do | |
params = ProtectedParams.new(name: "Guille", description: "desc") | |
params.permit! | |
model = Model.new(params) | |
assert_equal "Guille", model.name | |
assert_equal "desc", model.description | |
end | |
test "regular hash should still be used for mass assignment" do | |
model = Model.new(name: "Guille", description: "m") | |
assert_equal "Guille", model.name | |
assert_equal "m", model.description | |
end | |
test "assigning no attributes should not raise, even if the hash is un-permitted" do | |
model = Model.new | |
assert_nil model.assign_attributes(ProtectedParams.new({})) | |
end | |
end |
# frozen_string_literal: true | |
require "mutex_m" | |
require "active_support/core_ext/enumerable" | |
module ActiveRecord | |
# = Active Record Attribute Methods | |
module AttributeMethods | |
extend ActiveSupport::Concern | |
include ActiveModel::AttributeMethods | |
included do | |
initialize_generated_modules | |
include Read | |
include Write | |
include BeforeTypeCast | |
include Query | |
include PrimaryKey | |
include TimeZoneConversion | |
include Dirty | |
include Serialization | |
end | |
RESTRICTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) | |
class GeneratedAttributeMethods < Module # :nodoc: | |
include Mutex_m | |
end | |
class << self | |
def dangerous_attribute_methods # :nodoc: | |
@dangerous_attribute_methods ||= ( | |
Base.instance_methods + | |
Base.private_instance_methods - | |
Base.superclass.instance_methods - | |
Base.superclass.private_instance_methods | |
).map { |m| -m.to_s }.to_set.freeze | |
end | |
end | |
module ClassMethods | |
def inherited(child_class) # :nodoc: | |
child_class.initialize_generated_modules | |
super | |
end | |
def initialize_generated_modules # :nodoc: | |
@generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethods.new) | |
private_constant :GeneratedAttributeMethods | |
@attribute_methods_generated = false | |
include @generated_attribute_methods | |
super | |
end | |
# Generates all the attribute related methods for columns in the database | |
# accessors, mutators and query methods. | |
def define_attribute_methods # :nodoc: | |
return false if @attribute_methods_generated | |
# Use a mutex; we don't want two threads simultaneously trying to define | |
# attribute methods. | |
generated_attribute_methods.synchronize do | |
return false if @attribute_methods_generated | |
superclass.define_attribute_methods unless base_class? | |
super(attribute_names) | |
@attribute_methods_generated = true | |
end | |
end | |
def undefine_attribute_methods # :nodoc: | |
generated_attribute_methods.synchronize do | |
super if defined?(@attribute_methods_generated) && @attribute_methods_generated | |
@attribute_methods_generated = false | |
end | |
end | |
# Raises an ActiveRecord::DangerousAttributeError exception when an | |
# \Active \Record method is defined in the model, otherwise +false+. | |
# | |
# class Person < ActiveRecord::Base | |
# def save | |
# 'already defined by Active Record' | |
# end | |
# end | |
# | |
# Person.instance_method_already_implemented?(:save) | |
# # => ActiveRecord::DangerousAttributeError: save is defined by Active Record. Check to make sure that you don't have an attribute or method with the same name. | |
# | |
# Person.instance_method_already_implemented?(:name) | |
# # => false | |
def instance_method_already_implemented?(method_name) | |
if dangerous_attribute_method?(method_name) | |
raise DangerousAttributeError, "#{method_name} is defined by Active Record. Check to make sure that you don't have an attribute or method with the same name." | |
end | |
if superclass == Base | |
super | |
else | |
# If ThisClass < ... < SomeSuperClass < ... < Base and SomeSuperClass | |
# defines its own attribute method, then we don't want to override that. | |
defined = method_defined_within?(method_name, superclass, Base) && | |
! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethods) | |
defined || super | |
end | |
end | |
# A method name is 'dangerous' if it is already (re)defined by Active Record, but | |
# not by any ancestors. (So 'puts' is not dangerous but 'save' is.) | |
def dangerous_attribute_method?(name) # :nodoc: | |
::ActiveRecord::AttributeMethods.dangerous_attribute_methods.include?(name.to_s) | |
end | |
def method_defined_within?(name, klass, superklass = klass.superclass) # :nodoc: | |
if klass.method_defined?(name) || klass.private_method_defined?(name) | |
if superklass.method_defined?(name) || superklass.private_method_defined?(name) | |
klass.instance_method(name).owner != superklass.instance_method(name).owner | |
else | |
true | |
end | |
else | |
false | |
end | |
end | |
# A class method is 'dangerous' if it is already (re)defined by Active Record, but | |
# not by any ancestors. (So 'puts' is not dangerous but 'new' is.) | |
def dangerous_class_method?(method_name) | |
return true if RESTRICTED_CLASS_METHODS.include?(method_name.to_s) | |
if Base.respond_to?(method_name, true) | |
if Object.respond_to?(method_name, true) | |
Base.method(method_name).owner != Object.method(method_name).owner | |
else | |
true | |
end | |
else | |
false | |
end | |
end | |
# Returns +true+ if +attribute+ is an attribute method and table exists, | |
# +false+ otherwise. | |
# | |
# class Person < ActiveRecord::Base | |
# end | |
# | |
# Person.attribute_method?('name') # => true | |
# Person.attribute_method?(:age=) # => true | |
# Person.attribute_method?(:nothing) # => false | |
def attribute_method?(attribute) | |
super || (table_exists? && column_names.include?(attribute.to_s.delete_suffix("="))) | |
end | |
# Returns an array of column names as strings if it's not an abstract class and | |
# table exists. Otherwise it returns an empty array. | |
# | |
# class Person < ActiveRecord::Base | |
# end | |
# | |
# Person.attribute_names | |
# # => ["id", "created_at", "updated_at", "name", "age"] | |
def attribute_names | |
@attribute_names ||= if !abstract_class? && table_exists? | |
attribute_types.keys | |
else | |
[] | |
end.freeze | |
end | |
# Returns true if the given attribute exists, otherwise false. | |
# | |
# class Person < ActiveRecord::Base | |
# alias_attribute :new_name, :name | |
# end | |
# | |
# Person.has_attribute?('name') # => true | |
# Person.has_attribute?('new_name') # => true | |
# Person.has_attribute?(:age) # => true | |
# Person.has_attribute?(:nothing) # => false | |
def has_attribute?(attr_name) | |
attr_name = attr_name.to_s | |
attr_name = attribute_aliases[attr_name] || attr_name | |
attribute_types.key?(attr_name) | |
end | |
def _has_attribute?(attr_name) # :nodoc: | |
attribute_types.key?(attr_name) | |
end | |
end | |
# A Person object with a name attribute can ask <tt>person.respond_to?(:name)</tt>, | |
# <tt>person.respond_to?(:name=)</tt>, and <tt>person.respond_to?(:name?)</tt> | |
# which will all return +true+. It also defines the attribute methods if they have | |
# not been generated. | |
# | |
# class Person < ActiveRecord::Base | |
# end | |
# | |
# person = Person.new | |
# person.respond_to?(:name) # => true | |
# person.respond_to?(:name=) # => true | |
# person.respond_to?(:name?) # => true | |
# person.respond_to?('age') # => true | |
# person.respond_to?('age=') # => true | |
# person.respond_to?('age?') # => true | |
# person.respond_to?(:nothing) # => false | |
def respond_to?(name, include_private = false) | |
return false unless super | |
# If the result is true then check for the select case. | |
# For queries selecting a subset of columns, return false for unselected columns. | |
# We check defined?(@attributes) not to issue warnings if called on objects that | |
# have been allocated but not yet initialized. | |
if defined?(@attributes) | |
if name = self.class.symbol_column_to_string(name.to_sym) | |
return _has_attribute?(name) | |
end | |
end | |
true | |
end | |
# Returns +true+ if the given attribute is in the attributes hash, otherwise +false+. | |
# | |
# class Person < ActiveRecord::Base | |
# alias_attribute :new_name, :name | |
# end | |
# | |
# person = Person.new | |
# person.has_attribute?(:name) # => true | |
# person.has_attribute?(:new_name) # => true | |
# person.has_attribute?('age') # => true | |
# person.has_attribute?(:nothing) # => false | |
def has_attribute?(attr_name) | |
attr_name = attr_name.to_s | |
attr_name = self.class.attribute_aliases[attr_name] || attr_name | |
@attributes.key?(attr_name) | |
end | |
def _has_attribute?(attr_name) # :nodoc: | |
@attributes.key?(attr_name) | |
end | |
# Returns an array of names for the attributes available on this object. | |
# | |
# class Person < ActiveRecord::Base | |
# end | |
# | |
# person = Person.new | |
# person.attribute_names | |
# # => ["id", "created_at", "updated_at", "name", "age"] | |
def attribute_names | |
@attributes.keys | |
end | |
# Returns a hash of all the attributes with their names as keys and the values of the attributes as values. | |
# | |
# class Person < ActiveRecord::Base | |
# end | |
# | |
# person = Person.create(name: 'Francesco', age: 22) | |
# person.attributes | |
# # => {"id"=>3, "created_at"=>Sun, 21 Oct 2012 04:53:04, "updated_at"=>Sun, 21 Oct 2012 04:53:04, "name"=>"Francesco", "age"=>22} | |
def attributes | |
@attributes.to_hash | |
end | |
# Returns an <tt>#inspect</tt>-like string for the value of the | |
# attribute +attr_name+. String attributes are truncated up to 50 | |
# characters. Other attributes return the value of <tt>#inspect</tt> | |
# without modification. | |
# | |
# person = Person.create!(name: 'David Heinemeier Hansson ' * 3) | |
# | |
# person.attribute_for_inspect(:name) | |
# # => "\"David Heinemeier Hansson David Heinemeier Hansson ...\"" | |
# | |
# person.attribute_for_inspect(:created_at) | |
# # => "\"2012-10-22 00:15:07.000000000 +0000\"" | |
# | |
# person.attribute_for_inspect(:tag_ids) | |
# # => "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]" | |
def attribute_for_inspect(attr_name) | |
attr_name = attr_name.to_s | |
attr_name = self.class.attribute_aliases[attr_name] || attr_name | |
value = _read_attribute(attr_name) | |
format_for_inspect(attr_name, value) | |
end | |
# Returns +true+ if the specified +attribute+ has been set by the user or by a | |
# database load and is neither +nil+ nor <tt>empty?</tt> (the latter only applies | |
# to objects that respond to <tt>empty?</tt>, most notably Strings). Otherwise, +false+. | |
# Note that it always returns +true+ with boolean attributes. | |
# | |
# class Task < ActiveRecord::Base | |
# end | |
# | |
# task = Task.new(title: '', is_done: false) | |
# task.attribute_present?(:title) # => false | |
# task.attribute_present?(:is_done) # => true | |
# task.title = 'Buy milk' | |
# task.is_done = true | |
# task.attribute_present?(:title) # => true | |
# task.attribute_present?(:is_done) # => true | |
def attribute_present?(attr_name) | |
attr_name = attr_name.to_s | |
attr_name = self.class.attribute_aliases[attr_name] || attr_name | |
value = _read_attribute(attr_name) | |
!value.nil? && !(value.respond_to?(:empty?) && value.empty?) | |
end | |
# Returns the value of the attribute identified by <tt>attr_name</tt> after it has been typecast (for example, | |
# "2004-12-12" in a date column is cast to a date object, like Date.new(2004, 12, 12)). It raises | |
# <tt>ActiveModel::MissingAttributeError</tt> if the identified attribute is missing. | |
# | |
# Note: +:id+ is always present. | |
# | |
# class Person < ActiveRecord::Base | |
# belongs_to :organization | |
# end | |
# | |
# person = Person.new(name: 'Francesco', age: '22') | |
# person[:name] # => "Francesco" | |
# person[:age] # => 22 | |
# | |
# person = Person.select('id').first | |
# person[:name] # => ActiveModel::MissingAttributeError: missing attribute: name | |
# person[:organization_id] # => ActiveModel::MissingAttributeError: missing attribute: organization_id | |
def [](attr_name) | |
read_attribute(attr_name) { |n| missing_attribute(n, caller) } | |
end | |
# Updates the attribute identified by <tt>attr_name</tt> with the specified +value+. | |
# (Alias for the protected #write_attribute method). | |
# | |
# class Person < ActiveRecord::Base | |
# end | |
# | |
# person = Person.new | |
# person[:age] = '22' | |
# person[:age] # => 22 | |
# person[:age].class # => Integer | |
def []=(attr_name, value) | |
write_attribute(attr_name, value) | |
end | |
# Returns the name of all database fields which have been read from this | |
# model. This can be useful in development mode to determine which fields | |
# need to be selected. For performance critical pages, selecting only the | |
# required fields can be an easy performance win (assuming you aren't using | |
# all of the fields on the model). | |
# | |
# For example: | |
# | |
# class PostsController < ActionController::Base | |
# after_action :print_accessed_fields, only: :index | |
# | |
# def index | |
# @posts = Post.all | |
# end | |
# | |
# private | |
# | |
# def print_accessed_fields | |
# p @posts.first.accessed_fields | |
# end | |
# end | |
# | |
# Which allows you to quickly change your code to: | |
# | |
# class PostsController < ActionController::Base | |
# def index | |
# @posts = Post.select(:id, :title, :author_id, :updated_at) | |
# end | |
# end | |
def accessed_fields | |
@attributes.accessed | |
end | |
private | |
def attribute_method?(attr_name) | |
# We check defined? because Syck calls respond_to? before actually calling initialize. | |
defined?(@attributes) && @attributes.key?(attr_name) | |
end | |
def attributes_with_values(attribute_names) | |
attribute_names.index_with { |name| @attributes[name] } | |
end | |
# Filters the primary keys, readonly attributes and virtual columns from the attribute names. | |
def attributes_for_update(attribute_names) | |
attribute_names &= self.class.column_names | |
attribute_names.delete_if do |name| | |
self.class.readonly_attribute?(name) || | |
column_for_attribute(name).virtual? | |
end | |
end | |
# Filters out the virtual columns and also primary keys, from the attribute names, when the primary | |
# key is to be generated (e.g. the id attribute has no value). | |
def attributes_for_create(attribute_names) | |
attribute_names &= self.class.column_names | |
attribute_names.delete_if do |name| | |
(pk_attribute?(name) && id.nil?) || | |
column_for_attribute(name).virtual? | |
end | |
end | |
def format_for_inspect(name, value) | |
if value.nil? | |
value.inspect | |
else | |
inspected_value = if value.is_a?(String) && value.length > 50 | |
"#{value[0, 50]}...".inspect | |
elsif value.is_a?(Date) || value.is_a?(Time) | |
%("#{value.to_fs(:inspect)}") | |
else | |
value.inspect | |
end | |
inspection_filter.filter_param(name, inspected_value) | |
end | |
end | |
def pk_attribute?(name) | |
name == @primary_key | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/minimalistic" | |
require "models/developer" | |
require "models/auto_id" | |
require "models/boolean" | |
require "models/computer" | |
require "models/topic" | |
require "models/company" | |
require "models/category" | |
require "models/reply" | |
require "models/contact" | |
require "models/keyboard" | |
require "models/numeric_data" | |
class AttributeMethodsTest < ActiveRecord::TestCase | |
include InTimeZone | |
fixtures :topics, :developers, :companies, :computers | |
def setup | |
@old_matchers = ActiveRecord::Base.send(:attribute_method_patterns).dup | |
@target = Class.new(ActiveRecord::Base) | |
@target.table_name = "topics" | |
end | |
teardown do | |
ActiveRecord::Base.send(:attribute_method_patterns).clear | |
ActiveRecord::Base.send(:attribute_method_patterns).concat(@old_matchers) | |
end | |
test "attribute_for_inspect with a string" do | |
t = topics(:first) | |
t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters" | |
assert_equal '"The First Topic Now Has A Title With\nNewlines And ..."', t.attribute_for_inspect(:title) | |
assert_equal '"The First Topic Now Has A Title With\nNewlines And ..."', t.attribute_for_inspect(:heading) | |
end | |
test "attribute_for_inspect with a date" do | |
t = topics(:first) | |
assert_equal %("#{t.written_on.to_fs(:inspect)}"), t.attribute_for_inspect(:written_on) | |
end | |
test "attribute_for_inspect with an array" do | |
t = topics(:first) | |
t.content = [Object.new] | |
assert_match %r(\[#<Object:0x[0-9a-f]+>\]), t.attribute_for_inspect(:content) | |
end | |
test "attribute_for_inspect with a long array" do | |
t = topics(:first) | |
t.content = (1..11).to_a | |
assert_equal "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]", t.attribute_for_inspect(:content) | |
end | |
test "attribute_for_inspect with a non-primary key id attribute" do | |
t = topics(:first).becomes(TitlePrimaryKeyTopic) | |
t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters" | |
assert_equal "1", t.attribute_for_inspect(:id) | |
end | |
test "attribute_present" do | |
t = Topic.new | |
t.title = "hello there!" | |
t.written_on = Time.now | |
t.author_name = "" | |
assert t.attribute_present?("title") | |
assert t.attribute_present?("heading") | |
assert t.attribute_present?("written_on") | |
assert_not t.attribute_present?("content") | |
assert_not t.attribute_present?("author_name") | |
end | |
test "attribute_present with booleans" do | |
b1 = Boolean.new | |
b1.value = false | |
assert b1.attribute_present?(:value) | |
b2 = Boolean.new | |
b2.value = true | |
assert b2.attribute_present?(:value) | |
b3 = Boolean.new | |
assert_not b3.attribute_present?(:value) | |
b4 = Boolean.new | |
b4.value = false | |
b4.save! | |
assert Boolean.find(b4.id).attribute_present?(:value) | |
end | |
test "caching a nil primary key" do | |
klass = Class.new(Minimalistic) | |
assert_called(klass, :reset_primary_key, returns: nil) do | |
2.times { klass.primary_key } | |
end | |
end | |
test "attribute keys on a new instance" do | |
t = Topic.new | |
assert_nil t.title, "The topics table has a title column, so it should be nil" | |
assert_raise(NoMethodError) { t.title2 } | |
end | |
test "boolean attributes" do | |
assert_not_predicate Topic.find(1), :approved? | |
assert_predicate Topic.find(2), :approved? | |
end | |
test "set attributes" do | |
topic = Topic.find(1) | |
topic.attributes = { title: "Budget", author_name: "Jason" } | |
topic.save | |
assert_equal("Budget", topic.title) | |
assert_equal("Jason", topic.author_name) | |
assert_equal(topics(:first).author_email_address, Topic.find(1).author_email_address) | |
end | |
test "set attributes without a hash" do | |
topic = Topic.new | |
assert_raise(ArgumentError) { topic.attributes = "" } | |
end | |
test "integers as nil" do | |
test = AutoId.create(value: "") | |
assert_nil AutoId.find(test.id).value | |
end | |
test "set attributes with a block" do | |
topic = Topic.new do |t| | |
t.title = "Budget" | |
t.author_name = "Jason" | |
end | |
assert_equal("Budget", topic.title) | |
assert_equal("Jason", topic.author_name) | |
end | |
test "respond_to?" do | |
topic = Topic.find(1) | |
assert_respond_to topic, "title" | |
assert_respond_to topic, "title?" | |
assert_respond_to topic, "title=" | |
assert_respond_to topic, :title | |
assert_respond_to topic, :title? | |
assert_respond_to topic, :title= | |
assert_respond_to topic, "author_name" | |
assert_respond_to topic, "attribute_names" | |
assert_not_respond_to topic, "nothingness" | |
assert_not_respond_to topic, :nothingness | |
end | |
test "respond_to? with a custom primary key" do | |
keyboard = Keyboard.create | |
assert_not_nil keyboard.key_number | |
assert_equal keyboard.key_number, keyboard.id | |
assert_respond_to keyboard, "key_number" | |
assert_respond_to keyboard, "id" | |
end | |
test "id_before_type_cast with a custom primary key" do | |
keyboard = Keyboard.create | |
keyboard.key_number = "10" | |
assert_equal "10", keyboard.id_before_type_cast | |
assert_nil keyboard.read_attribute_before_type_cast("id") | |
assert_equal "10", keyboard.read_attribute_before_type_cast("key_number") | |
assert_equal "10", keyboard.read_attribute_before_type_cast(:key_number) | |
end | |
# IRB inspects the return value of MyModel.allocate. | |
test "allocated objects can be inspected" do | |
topic = Topic.allocate | |
assert_equal "#<Topic not initialized>", topic.inspect | |
end | |
test "array content" do | |
content = %w( one two three ) | |
topic = Topic.new | |
topic.content = content | |
topic.save | |
assert_equal content, Topic.find(topic.id).content | |
end | |
test "read attributes_before_type_cast" do | |
category = Category.new(name: "Test category", type: nil) | |
category_attrs = { "name" => "Test category", "id" => nil, "type" => nil, "categorizations_count" => nil } | |
assert_equal category_attrs, category.attributes_before_type_cast | |
end | |
if current_adapter?(:Mysql2Adapter) | |
test "read attributes_before_type_cast on a boolean" do | |
bool = Boolean.create!("value" => false) | |
assert_equal 0, bool.reload.attributes_before_type_cast["value"] | |
end | |
end | |
test "read attributes_before_type_cast on a datetime" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.written_on = "345643456" | |
assert_equal "345643456", record.written_on_before_type_cast | |
assert_nil record.written_on | |
record.written_on = "2009-10-11 12:13:14" | |
assert_equal "2009-10-11 12:13:14", record.written_on_before_type_cast | |
assert_equal Time.zone.parse("2009-10-11 12:13:14"), record.written_on | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone | |
end | |
end | |
test "read attributes_for_database" do | |
topic = Topic.new | |
topic.content = { one: 1, two: 2 } | |
db_attributes = Topic.instantiate(topic.attributes_for_database).attributes | |
before_type_cast_attributes = Topic.instantiate(topic.attributes_before_type_cast).attributes | |
assert_equal topic.attributes, db_attributes | |
assert_not_equal topic.attributes, before_type_cast_attributes | |
end | |
test "read attributes_after_type_cast on a date" do | |
tz = "Pacific Time (US & Canada)" | |
in_time_zone tz do | |
record = @target.new | |
date_string = "2011-03-24" | |
time = Time.zone.parse date_string | |
record.written_on = date_string | |
assert_equal date_string, record.written_on_before_type_cast | |
assert_equal time, record.written_on | |
assert_equal ActiveSupport::TimeZone[tz], record.written_on.time_zone | |
record.save | |
record.reload | |
assert_equal time, record.written_on | |
end | |
end | |
test "hash content" do | |
topic = Topic.new | |
topic.content = { "one" => 1, "two" => 2 } | |
topic.save | |
assert_equal 2, Topic.find(topic.id).content["two"] | |
topic.content_will_change! | |
topic.content["three"] = 3 | |
topic.save | |
assert_equal 3, Topic.find(topic.id).content["three"] | |
end | |
test "update array content" do | |
topic = Topic.new | |
topic.content = %w( one two three ) | |
topic.content.push "four" | |
assert_equal(%w( one two three four ), topic.content) | |
topic.save | |
topic = Topic.find(topic.id) | |
topic.content << "five" | |
assert_equal(%w( one two three four five ), topic.content) | |
end | |
test "case-sensitive attributes hash" do | |
expected = ["created_at", "developer", "extendedWarranty", "id", "system", "timezone", "updated_at"] | |
assert_equal expected, Computer.first.attributes.keys.sort | |
end | |
test "attributes without primary key" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = "developers_projects" | |
end | |
assert_equal klass.column_names, klass.new.attributes.keys | |
assert_not klass.new.has_attribute?("id") | |
end | |
test "hashes are not mangled" do | |
new_topic = { title: "New Topic", content: { key: "First value" } } | |
new_topic_values = { title: "AnotherTopic", content: { key: "Second value" } } | |
topic = Topic.new(new_topic) | |
assert_equal new_topic[:title], topic.title | |
assert_equal new_topic[:content], topic.content | |
topic.attributes = new_topic_values | |
assert_equal new_topic_values[:title], topic.title | |
assert_equal new_topic_values[:content], topic.content | |
end | |
test "create through factory" do | |
topic = Topic.create(title: "New Topic") | |
topicReloaded = Topic.find(topic.id) | |
assert_equal(topic, topicReloaded) | |
end | |
test "write_attribute" do | |
topic = Topic.new | |
topic.write_attribute :title, "Still another topic" | |
assert_equal "Still another topic", topic.title | |
topic[:title] = "Still another topic: part 2" | |
assert_equal "Still another topic: part 2", topic.title | |
topic.write_attribute "title", "Still another topic: part 3" | |
assert_equal "Still another topic: part 3", topic.title | |
topic["title"] = "Still another topic: part 4" | |
assert_equal "Still another topic: part 4", topic.title | |
end | |
test "write_attribute can write aliased attributes as well" do | |
topic = Topic.new(title: "Don't change the topic") | |
topic.write_attribute :heading, "New topic" | |
assert_equal "New topic", topic.title | |
end | |
test "write_attribute raises ActiveModel::MissingAttributeError when the attribute does not exist" do | |
topic = Topic.first | |
assert_raises(ActiveModel::MissingAttributeError) { topic.update_columns(no_column_exists: "Hello!") } | |
assert_raises(ActiveModel::UnknownAttributeError) { topic.update(no_column_exists: "Hello!") } | |
end | |
test "write_attribute allows writing to aliased attributes" do | |
topic = Topic.first | |
assert_nothing_raised { topic.update_columns(heading: "Hello!") } | |
assert_nothing_raised { topic.update(heading: "Hello!") } | |
end | |
test "read_attribute" do | |
topic = Topic.new | |
topic.title = "Don't change the topic" | |
assert_equal "Don't change the topic", topic.read_attribute("title") | |
assert_equal "Don't change the topic", topic["title"] | |
assert_equal "Don't change the topic", topic.read_attribute(:title) | |
assert_equal "Don't change the topic", topic[:title] | |
end | |
test "read_attribute can read aliased attributes as well" do | |
topic = Topic.new(title: "Don't change the topic") | |
assert_equal "Don't change the topic", topic.read_attribute("heading") | |
assert_equal "Don't change the topic", topic["heading"] | |
assert_equal "Don't change the topic", topic.read_attribute(:heading) | |
assert_equal "Don't change the topic", topic[:heading] | |
end | |
test "read_attribute raises ActiveModel::MissingAttributeError when the attribute does not exist" do | |
computer = Computer.select("id").first | |
assert_raises(ActiveModel::MissingAttributeError) { computer[:developer] } | |
assert_raises(ActiveModel::MissingAttributeError) { computer[:extendedWarranty] } | |
assert_raises(ActiveModel::MissingAttributeError) { computer[:no_column_exists] = "Hello!" } | |
assert_nothing_raised { computer[:developer] = "Hello!" } | |
end | |
test "read_attribute when false" do | |
topic = topics(:first) | |
topic.approved = false | |
assert_not topic.approved?, "approved should be false" | |
topic.approved = "false" | |
assert_not topic.approved?, "approved should be false" | |
end | |
test "read_attribute when true" do | |
topic = topics(:first) | |
topic.approved = true | |
assert topic.approved?, "approved should be true" | |
topic.approved = "true" | |
assert topic.approved?, "approved should be true" | |
end | |
test "boolean attributes writing and reading" do | |
topic = Topic.new | |
topic.approved = "false" | |
assert_not topic.approved?, "approved should be false" | |
topic.approved = "false" | |
assert_not topic.approved?, "approved should be false" | |
topic.approved = "true" | |
assert topic.approved?, "approved should be true" | |
topic.approved = "true" | |
assert topic.approved?, "approved should be true" | |
end | |
test "overridden write_attribute" do | |
topic = Topic.new | |
def topic.write_attribute(attr_name, value) | |
super(attr_name, value.downcase) | |
end | |
topic.write_attribute :title, "Yet another topic" | |
assert_equal "yet another topic", topic.title | |
topic[:title] = "Yet another topic: part 2" | |
assert_equal "yet another topic: part 2", topic.title | |
topic.write_attribute "title", "Yet another topic: part 3" | |
assert_equal "yet another topic: part 3", topic.title | |
topic["title"] = "Yet another topic: part 4" | |
assert_equal "yet another topic: part 4", topic.title | |
end | |
test "overridden read_attribute" do | |
topic = Topic.new | |
topic.title = "Stop changing the topic" | |
def topic.read_attribute(attr_name) | |
super(attr_name).upcase | |
end | |
assert_equal "STOP CHANGING THE TOPIC", topic.read_attribute("title") | |
assert_equal "STOP CHANGING THE TOPIC", topic["title"] | |
assert_equal "STOP CHANGING THE TOPIC", topic.read_attribute(:title) | |
assert_equal "STOP CHANGING THE TOPIC", topic[:title] | |
end | |
test "read overridden attribute" do | |
topic = Topic.new(title: "a") | |
def topic.title() "b" end | |
assert_equal "a", topic[:title] | |
end | |
test "read overridden attribute with predicate respects override" do | |
topic = Topic.new | |
topic.approved = true | |
def topic.approved; false; end | |
assert_not topic.approved?, "overridden approved should be false" | |
end | |
test "string attribute predicate" do | |
[nil, "", " "].each do |value| | |
assert_equal false, Topic.new(author_name: value).author_name? | |
end | |
assert_equal true, Topic.new(author_name: "Name").author_name? | |
ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| | |
assert_predicate Topic.new(author_name: value), :author_name? | |
end | |
end | |
test "number attribute predicate" do | |
[nil, 0, "0"].each do |value| | |
assert_equal false, Developer.new(salary: value).salary? | |
end | |
assert_equal true, Developer.new(salary: 1).salary? | |
assert_equal true, Developer.new(salary: "1").salary? | |
end | |
test "boolean attribute predicate" do | |
[nil, "", false, "false", "f", 0].each do |value| | |
assert_equal false, Topic.new(approved: value).approved? | |
end | |
[true, "true", "1", 1].each do |value| | |
assert_equal true, Topic.new(approved: value).approved? | |
end | |
end | |
test "user-defined text attribute predicate" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = Topic.table_name | |
attribute :user_defined_text, :text | |
end | |
topic = klass.new(user_defined_text: "text") | |
assert_predicate topic, :user_defined_text? | |
ActiveModel::Type::Boolean::FALSE_VALUES.each do |value| | |
topic = klass.new(user_defined_text: value) | |
assert_predicate topic, :user_defined_text? | |
end | |
end | |
test "user-defined date attribute predicate" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = Topic.table_name | |
attribute :user_defined_date, :date | |
end | |
topic = klass.new(user_defined_date: Date.current) | |
assert_predicate topic, :user_defined_date? | |
end | |
test "user-defined datetime attribute predicate" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = Topic.table_name | |
attribute :user_defined_datetime, :datetime | |
end | |
topic = klass.new(user_defined_datetime: Time.current) | |
assert_predicate topic, :user_defined_datetime? | |
end | |
test "user-defined time attribute predicate" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = Topic.table_name | |
attribute :user_defined_time, :time | |
end | |
topic = klass.new(user_defined_time: Time.current) | |
assert_predicate topic, :user_defined_time? | |
end | |
test "user-defined json attribute predicate" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = Topic.table_name | |
attribute :user_defined_json, :json | |
end | |
topic = klass.new(user_defined_json: { key: "value" }) | |
assert_predicate topic, :user_defined_json? | |
topic = klass.new(user_defined_json: {}) | |
assert_not_predicate topic, :user_defined_json? | |
end | |
test "custom field attribute predicate" do | |
object = Company.find_by_sql(<<~SQL).first | |
SELECT c1.*, c2.type as string_value, c2.rating as int_value | |
FROM companies c1, companies c2 | |
WHERE c1.firm_id = c2.id | |
AND c1.id = 2 | |
SQL | |
assert_equal "Firm", object.string_value | |
assert_predicate object, :string_value? | |
object.string_value = " " | |
assert_not_predicate object, :string_value? | |
assert_equal 1, object.int_value.to_i | |
assert_predicate object, :int_value? | |
object.int_value = "0" | |
assert_not_predicate object, :int_value? | |
end | |
test "non-attribute read and write" do | |
topic = Topic.new | |
assert_not_respond_to topic, "mumbo" | |
assert_raise(NoMethodError) { topic.mumbo } | |
assert_raise(NoMethodError) { topic.mumbo = 5 } | |
end | |
test "undeclared attribute method does not affect respond_to? and method_missing" do | |
topic = @target.new(title: "Budget") | |
assert_respond_to topic, "title" | |
assert_equal "Budget", topic.title | |
assert_not_respond_to topic, "title_hello_world" | |
assert_raise(NoMethodError) { topic.title_hello_world } | |
end | |
test "declared prefixed attribute method affects respond_to? and method_missing" do | |
topic = @target.new(title: "Budget") | |
%w(default_ title_).each do |prefix| | |
@target.class_eval "def #{prefix}attribute(*args) args end" | |
@target.attribute_method_prefix prefix | |
meth = "#{prefix}title" | |
assert_respond_to topic, meth | |
assert_equal ["title"], topic.public_send(meth) | |
assert_equal ["title", "a"], topic.public_send(meth, "a") | |
assert_equal ["title", 1, 2, 3], topic.public_send(meth, 1, 2, 3) | |
end | |
end | |
test "declared suffixed attribute method affects respond_to? and method_missing" do | |
%w(_default _title_default _it! _candidate= able?).each do |suffix| | |
@target.class_eval "def attribute#{suffix}(*args) args end" | |
@target.attribute_method_suffix suffix | |
topic = @target.new(title: "Budget") | |
meth = "title#{suffix}" | |
assert_respond_to topic, meth | |
assert_equal ["title"], topic.public_send(meth) | |
assert_equal ["title", "a"], topic.public_send(meth, "a") | |
assert_equal ["title", 1, 2, 3], topic.public_send(meth, 1, 2, 3) | |
end | |
end | |
test "declared affixed attribute method affects respond_to? and method_missing" do | |
[["mark_", "_for_update"], ["reset_", "!"], ["default_", "_value?"]].each do |prefix, suffix| | |
@target.class_eval "def #{prefix}attribute#{suffix}(*args) args end" | |
@target.attribute_method_affix(prefix: prefix, suffix: suffix) | |
topic = @target.new(title: "Budget") | |
meth = "#{prefix}title#{suffix}" | |
assert_respond_to topic, meth | |
assert_equal ["title"], topic.public_send(meth) | |
assert_equal ["title", "a"], topic.public_send(meth, "a") | |
assert_equal ["title", 1, 2, 3], topic.public_send(meth, 1, 2, 3) | |
end | |
end | |
test "should unserialize attributes for frozen records" do | |
myobj = { value1: :value2 } | |
topic = Topic.create(content: myobj) | |
topic.freeze | |
assert_equal myobj, topic.content | |
end | |
test "typecast attribute from select to false" do | |
Topic.create(title: "Budget") | |
# Oracle does not support boolean expressions in SELECT. | |
if current_adapter?(:OracleAdapter) | |
topic = Topic.all.merge!(select: "topics.*, 0 as is_test").first | |
else | |
topic = Topic.all.merge!(select: "topics.*, 1=2 as is_test").first | |
end | |
assert_not_predicate topic, :is_test? | |
end | |
test "typecast attribute from select to true" do | |
Topic.create(title: "Budget") | |
# Oracle does not support boolean expressions in SELECT. | |
if current_adapter?(:OracleAdapter) | |
topic = Topic.all.merge!(select: "topics.*, 1 as is_test").first | |
else | |
topic = Topic.all.merge!(select: "topics.*, 2=2 as is_test").first | |
end | |
assert_predicate topic, :is_test? | |
end | |
test "raises ActiveRecord::DangerousAttributeError when defining an AR method in a model" do | |
%w(save create_or_update).each do |method| | |
klass = Class.new(ActiveRecord::Base) | |
klass.class_eval "def #{method}() 'defined #{method}' end" | |
assert_raise ActiveRecord::DangerousAttributeError do | |
klass.instance_method_already_implemented?(method) | |
end | |
end | |
end | |
test "converted values are returned after assignment" do | |
developer = Developer.new(name: 1337, salary: "50000") | |
assert_equal "50000", developer.salary_before_type_cast | |
assert_equal 1337, developer.name_before_type_cast | |
assert_equal 50000, developer.salary | |
assert_equal "1337", developer.name | |
developer.save! | |
assert_equal 50000, developer.salary | |
assert_equal "1337", developer.name | |
end | |
test "write nil to time attribute" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.written_on = nil | |
assert_nil record.written_on | |
end | |
end | |
test "write time to date attribute" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.last_read = Time.utc(2010, 1, 1, 10) | |
assert_equal Date.civil(2010, 1, 1), record.last_read | |
end | |
end | |
test "time attributes are retrieved in the current time zone" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
utc_time = Time.utc(2008, 1, 1) | |
record = @target.new | |
record[:written_on] = utc_time | |
assert_equal utc_time, record.written_on # record.written on is equal to (i.e., simultaneous with) utc_time | |
assert_kind_of ActiveSupport::TimeWithZone, record.written_on # but is a TimeWithZone | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone # and is in the current Time.zone | |
assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time # and represents time values adjusted accordingly | |
end | |
end | |
test "setting a time zone-aware attribute to UTC" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
utc_time = Time.utc(2008, 1, 1) | |
record = @target.new | |
record.written_on = utc_time | |
assert_equal utc_time, record.written_on | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone | |
assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time | |
end | |
end | |
test "setting time zone-aware attribute in other time zone" do | |
utc_time = Time.utc(2008, 1, 1) | |
cst_time = utc_time.in_time_zone("Central Time (US & Canada)") | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.written_on = cst_time | |
assert_equal utc_time, record.written_on | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone | |
assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time | |
end | |
end | |
test "setting time zone-aware read attribute" do | |
utc_time = Time.utc(2008, 1, 1) | |
cst_time = utc_time.in_time_zone("Central Time (US & Canada)") | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.create(written_on: cst_time).reload | |
assert_equal utc_time, record[:written_on] | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record[:written_on].time_zone | |
assert_equal Time.utc(2007, 12, 31, 16), record[:written_on].time | |
end | |
end | |
test "setting time zone-aware attribute with a string" do | |
utc_time = Time.utc(2008, 1, 1) | |
(-11..13).each do |timezone_offset| | |
time_string = utc_time.in_time_zone(timezone_offset).to_s | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.written_on = time_string | |
assert_equal Time.zone.parse(time_string), record.written_on | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone | |
assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time | |
end | |
end | |
end | |
test "time zone-aware attribute saved" do | |
in_time_zone 1 do | |
record = @target.create(written_on: "2012-02-20 10:00") | |
record.written_on = "2012-02-20 09:00" | |
record.save | |
assert_equal Time.zone.local(2012, 02, 20, 9), record.reload.written_on | |
end | |
end | |
test "setting a time zone-aware attribute to a blank string returns nil" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.written_on = " " | |
assert_nil record.written_on | |
assert_nil record[:written_on] | |
end | |
end | |
test "setting a time zone-aware attribute interprets time zone-unaware string in time zone" do | |
time_string = "Tue Jan 01 00:00:00 2008" | |
(-11..13).each do |timezone_offset| | |
in_time_zone timezone_offset do | |
record = @target.new | |
record.written_on = time_string | |
assert_equal Time.zone.parse(time_string), record.written_on | |
assert_equal ActiveSupport::TimeZone[timezone_offset], record.written_on.time_zone | |
assert_equal Time.utc(2008, 1, 1), record.written_on.time | |
end | |
end | |
end | |
test "setting a time zone-aware datetime in the current time zone" do | |
utc_time = Time.utc(2008, 1, 1) | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
record.written_on = utc_time.in_time_zone | |
assert_equal utc_time, record.written_on | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone | |
assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time | |
end | |
end | |
test "YAML dumping a record with time zone-aware attribute" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = Topic.new(id: 1) | |
record.written_on = "Jan 01 00:00:00 2014" | |
payload = YAML.dump(record) | |
assert_equal record, YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(payload) : YAML.load(payload) | |
end | |
ensure | |
# NOTE: Reset column info because global topics | |
# don't have tz-aware attributes by default. | |
Topic.reset_column_information | |
end | |
test "setting a time zone-aware time in the current time zone" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
time_string = "10:00:00" | |
expected_time = Time.zone.parse("2000-01-01 #{time_string}") | |
record.bonus_time = time_string | |
assert_equal expected_time, record.bonus_time | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.bonus_time.time_zone | |
record.bonus_time = "" | |
assert_nil record.bonus_time | |
end | |
end | |
test "setting a time zone-aware time with DST" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
current_time = Time.zone.local(2014, 06, 15, 10) | |
record = @target.new(bonus_time: current_time) | |
time_before_save = record.bonus_time | |
record.save | |
record.reload | |
assert_equal time_before_save, record.bonus_time | |
assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.bonus_time.time_zone | |
end | |
end | |
test "setting invalid string to a zone-aware time attribute" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new | |
time_string = "ABC" | |
record.bonus_time = time_string | |
assert_nil record.bonus_time | |
end | |
end | |
test "removing time zone-aware types" do | |
with_time_zone_aware_types(:datetime) do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new(bonus_time: "10:00:00") | |
expected_time = Time.utc(2000, 01, 01, 10) | |
assert_equal expected_time, record.bonus_time | |
assert_predicate record.bonus_time, :utc? | |
end | |
end | |
end | |
test "time zone-aware attributes do not recurse infinitely on invalid values" do | |
in_time_zone "Pacific Time (US & Canada)" do | |
record = @target.new(bonus_time: []) | |
assert_nil record.bonus_time | |
end | |
end | |
test "setting a time_zone_conversion_for_attributes should write the value on a class variable" do | |
Topic.skip_time_zone_conversion_for_attributes = [:field_a] | |
Minimalistic.skip_time_zone_conversion_for_attributes = [:field_b] | |
assert_equal [:field_a], Topic.skip_time_zone_conversion_for_attributes | |
assert_equal [:field_b], Minimalistic.skip_time_zone_conversion_for_attributes | |
end | |
test "attribute readers respect access control" do | |
privatize("title") | |
topic = @target.new(title: "The pros and cons of programming naked.") | |
assert_not_respond_to topic, :title | |
exception = assert_raise(NoMethodError) { topic.title } | |
assert_includes exception.message, "private method" | |
assert_equal "I'm private", topic.send(:title) | |
end | |
test "attribute writers respect access control" do | |
privatize("title=(value)") | |
topic = @target.new | |
assert_not_respond_to topic, :title= | |
exception = assert_raise(NoMethodError) { topic.title = "Pants" } | |
assert_includes exception.message, "private method" | |
topic.send(:title=, "Very large pants") | |
end | |
test "attribute predicates respect access control" do | |
privatize("title?") | |
topic = @target.new(title: "Isaac Newton's pants") | |
assert_not_respond_to topic, :title? | |
exception = assert_raise(NoMethodError) { topic.title? } | |
assert_includes exception.message, "private method" | |
assert topic.send(:title?) | |
end | |
test "bulk updates respect access control" do | |
privatize("title=(value)") | |
assert_raise(ActiveRecord::UnknownAttributeError) { @target.new(title: "Rants about pants") } | |
assert_raise(ActiveRecord::UnknownAttributeError) { @target.new.attributes = { title: "Ants in pants" } } | |
end | |
test "bulk update raises ActiveRecord::UnknownAttributeError" do | |
error = assert_raises(ActiveRecord::UnknownAttributeError) { | |
Topic.new(hello: "world") | |
} | |
assert_instance_of Topic, error.record | |
assert_equal "hello", error.attribute | |
assert_match "unknown attribute 'hello' for Topic.", error.message | |
end | |
test "method overrides in multi-level subclasses" do | |
klass = Class.new(Developer) do | |
def name | |
"dev:#{read_attribute(:name)}" | |
end | |
end | |
2.times { klass = Class.new(klass) } | |
dev = klass.new(name: "arthurnn") | |
dev.save! | |
assert_equal "dev:arthurnn", dev.reload.name | |
end | |
test "global methods are overwritten" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = "computers" | |
end | |
assert_not klass.instance_method_already_implemented?(:system) | |
computer = klass.new | |
assert_nil computer.system | |
end | |
test "global methods are overwritten when subclassing" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.abstract_class = true | |
end | |
subklass = Class.new(klass) do | |
self.table_name = "computers" | |
end | |
assert_not klass.instance_method_already_implemented?(:system) | |
assert_not subklass.instance_method_already_implemented?(:system) | |
computer = subklass.new | |
assert_nil computer.system | |
end | |
test "instance methods should be defined on the base class" do | |
subklass = Class.new(Topic) | |
Topic.define_attribute_methods | |
instance = subklass.new | |
instance.id = 5 | |
assert_equal 5, instance.id | |
assert subklass.method_defined?(:id), "subklass is missing id method" | |
Topic.undefine_attribute_methods | |
assert_equal 5, instance.id | |
assert subklass.method_defined?(:id), "subklass is missing id method" | |
end | |
test "define_attribute_method works with both symbol and string" do | |
klass = Class.new(ActiveRecord::Base) | |
klass.table_name = "foo" | |
assert_nothing_raised { klass.define_attribute_method(:foo) } | |
assert_nothing_raised { klass.define_attribute_method("bar") } | |
end | |
test "read_attribute with nil should not asplode" do | |
assert_nil Topic.new.read_attribute(nil) | |
end | |
# If B < A, and A defines an accessor for 'foo', we don't want to override | |
# that by defining a 'foo' method in the generated methods module for B. | |
# (That module will be inserted between the two, e.g. [B, <GeneratedAttributes>, A].) | |
test "inherited custom accessors" do | |
klass = new_topic_like_ar_class do | |
self.abstract_class = true | |
def title; "omg"; end | |
def title=(val); self.author_name = val; end | |
end | |
subklass = Class.new(klass) | |
[klass, subklass].each(&:define_attribute_methods) | |
topic = subklass.find(1) | |
assert_equal "omg", topic.title | |
topic.title = "lol" | |
assert_equal "lol", topic.author_name | |
end | |
test "inherited custom accessors with reserved names" do | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = "computers" | |
self.abstract_class = true | |
def system; "omg"; end | |
def system=(val); self.developer = val; end | |
end | |
subklass = Class.new(klass) | |
[klass, subklass].each(&:define_attribute_methods) | |
computer = subklass.find(1) | |
assert_equal "omg", computer.system | |
computer.developer = 99 | |
assert_equal 99, computer.developer | |
end | |
test "on_the_fly_super_invokable_generated_attribute_methods_via_method_missing" do | |
klass = new_topic_like_ar_class do | |
def title | |
super + "!" | |
end | |
end | |
real_topic = topics(:first) | |
assert_equal real_topic.title + "!", klass.find(real_topic.id).title | |
end | |
test "on-the-fly super-invokable generated attribute predicates via method_missing" do | |
klass = new_topic_like_ar_class do | |
def title? | |
!super | |
end | |
end | |
real_topic = topics(:first) | |
assert_equal !real_topic.title?, klass.find(real_topic.id).title? | |
end | |
test "calling super when the parent does not define method raises NoMethodError" do | |
klass = new_topic_like_ar_class do | |
def some_method_that_is_not_on_super | |
super | |
end | |
end | |
assert_raise(NoMethodError) do | |
klass.new.some_method_that_is_not_on_super | |
end | |
end | |
test "attribute_method?" do | |
assert @target.attribute_method?(:title) | |
assert @target.attribute_method?(:title=) | |
assert_not @target.attribute_method?(:wibble) | |
end | |
test "attribute_method? returns false if the table does not exist" do | |
@target.table_name = "wibble" | |
assert_not @target.attribute_method?(:title) | |
end | |
test "attribute_names on a new record" do | |
model = @target.new | |
assert_equal @target.column_names, model.attribute_names | |
end | |
test "attribute_names on a queried record" do | |
model = @target.last! | |
assert_equal @target.column_names, model.attribute_names | |
end | |
test "attribute_names with a custom select" do | |
model = @target.select("id").last! | |
assert_equal ["id"], model.attribute_names | |
# Ensure other columns exist. | |
assert_not_equal ["id"], @target.column_names | |
end | |
test "came_from_user?" do | |
model = @target.first | |
assert_not_predicate model, :id_came_from_user? | |
model.id = "omg" | |
assert_predicate model, :id_came_from_user? | |
end | |
test "accessed_fields" do | |
model = @target.first | |
assert_equal [], model.accessed_fields | |
model.title | |
assert_equal ["title"], model.accessed_fields | |
end | |
test "generated attribute methods ancestors have correct module" do | |
mod = Topic.send(:generated_attribute_methods) | |
assert_equal "Topic::GeneratedAttributeMethods", mod.inspect | |
end | |
test "read_attribute_before_type_cast with aliased attribute" do | |
model = NumericData.new(new_bank_balance: "abcd") | |
assert_equal "abcd", model.read_attribute_before_type_cast("new_bank_balance") | |
end | |
private | |
def new_topic_like_ar_class(&block) | |
klass = Class.new(ActiveRecord::Base) do | |
self.table_name = "topics" | |
class_eval(&block) | |
end | |
assert_empty klass.send(:generated_attribute_methods).instance_methods(false) | |
klass | |
end | |
def with_time_zone_aware_types(*types) | |
old_types = ActiveRecord::Base.time_zone_aware_types | |
ActiveRecord::Base.time_zone_aware_types = types | |
yield | |
ensure | |
ActiveRecord::Base.time_zone_aware_types = old_types | |
end | |
def privatize(method_signature) | |
@target.class_eval(<<-private_method, __FILE__, __LINE__ + 1) | |
private | |
def #{method_signature} | |
"I'm private" | |
end | |
private_method | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/hash/indifferent_access" | |
require "active_support/core_ext/object/duplicable" | |
module ActiveModel | |
class AttributeMutationTracker # :nodoc: | |
OPTION_NOT_GIVEN = Object.new | |
def initialize(attributes) | |
@attributes = attributes | |
end | |
def changed_attribute_names | |
attr_names.select { |attr_name| changed?(attr_name) } | |
end | |
def changed_values | |
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| | |
if changed?(attr_name) | |
result[attr_name] = original_value(attr_name) | |
end | |
end | |
end | |
def changes | |
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| | |
if change = change_to_attribute(attr_name) | |
result.merge!(attr_name => change) | |
end | |
end | |
end | |
def change_to_attribute(attr_name) | |
if changed?(attr_name) | |
[original_value(attr_name), fetch_value(attr_name)] | |
end | |
end | |
def any_changes? | |
attr_names.any? { |attr| changed?(attr) } | |
end | |
def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) | |
attribute_changed?(attr_name) && | |
(OPTION_NOT_GIVEN == from || original_value(attr_name) == from) && | |
(OPTION_NOT_GIVEN == to || fetch_value(attr_name) == to) | |
end | |
def changed_in_place?(attr_name) | |
attributes[attr_name].changed_in_place? | |
end | |
def forget_change(attr_name) | |
attributes[attr_name] = attributes[attr_name].forgetting_assignment | |
forced_changes.delete(attr_name) | |
end | |
def original_value(attr_name) | |
attributes[attr_name].original_value | |
end | |
def force_change(attr_name) | |
forced_changes[attr_name] = fetch_value(attr_name) | |
end | |
private | |
attr_reader :attributes | |
def forced_changes | |
@forced_changes ||= {} | |
end | |
def attr_names | |
attributes.keys | |
end | |
def attribute_changed?(attr_name) | |
forced_changes.include?(attr_name) || !!attributes[attr_name].changed? | |
end | |
def fetch_value(attr_name) | |
attributes.fetch_value(attr_name) | |
end | |
end | |
class ForcedMutationTracker < AttributeMutationTracker # :nodoc: | |
def initialize(attributes) | |
super | |
@finalized_changes = nil | |
end | |
def changed_in_place?(attr_name) | |
false | |
end | |
def change_to_attribute(attr_name) | |
if finalized_changes&.include?(attr_name) | |
finalized_changes[attr_name].dup | |
else | |
super | |
end | |
end | |
def forget_change(attr_name) | |
forced_changes.delete(attr_name) | |
end | |
def original_value(attr_name) | |
if changed?(attr_name) | |
forced_changes[attr_name] | |
else | |
fetch_value(attr_name) | |
end | |
end | |
def force_change(attr_name) | |
forced_changes[attr_name] = clone_value(attr_name) unless attribute_changed?(attr_name) | |
end | |
def finalize_changes | |
@finalized_changes = changes | |
end | |
private | |
attr_reader :finalized_changes | |
def attr_names | |
forced_changes.keys | |
end | |
def attribute_changed?(attr_name) | |
forced_changes.include?(attr_name) | |
end | |
def fetch_value(attr_name) | |
attributes.send(:_read_attribute, attr_name) | |
end | |
def clone_value(attr_name) | |
value = fetch_value(attr_name) | |
value.duplicable? ? value.clone : value | |
rescue TypeError, NoMethodError | |
value | |
end | |
end | |
class NullMutationTracker # :nodoc: | |
include Singleton | |
def changed_attribute_names | |
[] | |
end | |
def changed_values | |
{} | |
end | |
def changes | |
{} | |
end | |
def change_to_attribute(attr_name) | |
end | |
def any_changes? | |
false | |
end | |
def changed?(attr_name, **) | |
false | |
end | |
def changed_in_place?(attr_name) | |
false | |
end | |
def original_value(attr_name) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/enumerable" | |
require "active_support/core_ext/object/deep_dup" | |
require "active_model/attribute_set/builder" | |
require "active_model/attribute_set/yaml_encoder" | |
module ActiveModel | |
class AttributeSet # :nodoc: | |
delegate :each_value, :fetch, :except, to: :attributes | |
def initialize(attributes) | |
@attributes = attributes | |
end | |
def [](name) | |
@attributes[name] || default_attribute(name) | |
end | |
def []=(name, value) | |
@attributes[name] = value | |
end | |
def values_before_type_cast | |
attributes.transform_values(&:value_before_type_cast) | |
end | |
def values_for_database | |
attributes.transform_values(&:value_for_database) | |
end | |
def to_hash | |
keys.index_with { |name| self[name].value } | |
end | |
alias :to_h :to_hash | |
def key?(name) | |
attributes.key?(name) && self[name].initialized? | |
end | |
def keys | |
attributes.each_key.select { |name| self[name].initialized? } | |
end | |
def fetch_value(name, &block) | |
self[name].value(&block) | |
end | |
def write_from_database(name, value) | |
@attributes[name] = self[name].with_value_from_database(value) | |
end | |
def write_from_user(name, value) | |
raise FrozenError, "can't modify frozen attributes" if frozen? | |
@attributes[name] = self[name].with_value_from_user(value) | |
value | |
end | |
def write_cast_value(name, value) | |
@attributes[name] = self[name].with_cast_value(value) | |
end | |
def freeze | |
attributes.freeze | |
super | |
end | |
def deep_dup | |
AttributeSet.new(attributes.deep_dup) | |
end | |
def initialize_dup(_) | |
@attributes = @attributes.dup | |
super | |
end | |
def initialize_clone(_) | |
@attributes = @attributes.clone | |
super | |
end | |
def reset(key) | |
if key?(key) | |
write_from_database(key, nil) | |
end | |
end | |
def accessed | |
attributes.each_key.select { |name| self[name].has_been_read? } | |
end | |
def map(&block) | |
new_attributes = attributes.transform_values(&block) | |
AttributeSet.new(new_attributes) | |
end | |
def ==(other) | |
attributes == other.attributes | |
end | |
protected | |
attr_reader :attributes | |
private | |
def default_attribute(name) | |
Attribute.null(name) | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "active_model/attribute_set" | |
module ActiveModel | |
class AttributeSetTest < ActiveModel::TestCase | |
test "building a new set from raw attributes" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) | |
attributes = builder.build_from_database(foo: "1.1", bar: "2.2") | |
assert_equal 1, attributes[:foo].value | |
assert_equal 2.2, attributes[:bar].value | |
assert_equal :foo, attributes[:foo].name | |
assert_equal :bar, attributes[:bar].name | |
end | |
test "building with custom types" do | |
builder = AttributeSet::Builder.new(foo: Type::Float.new) | |
attributes = builder.build_from_database({ foo: "3.3", bar: "4.4" }, { bar: Type::Integer.new }) | |
assert_equal 3.3, attributes[:foo].value | |
assert_equal 4, attributes[:bar].value | |
end | |
test "[] returns a null object" do | |
builder = AttributeSet::Builder.new(foo: Type::Float.new) | |
attributes = builder.build_from_database(foo: "3.3") | |
assert_equal "3.3", attributes[:foo].value_before_type_cast | |
assert_nil attributes[:bar].value_before_type_cast | |
assert_equal :bar, attributes[:bar].name | |
end | |
test "duping creates a new hash, but does not dup the attributes" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) | |
attributes = builder.build_from_database(foo: 1, bar: "foo") | |
# Ensure the type cast value is cached | |
attributes[:foo].value | |
attributes[:bar].value | |
duped = attributes.dup | |
duped.write_from_database(:foo, 2) | |
duped[:bar].value << "bar" | |
assert_equal 1, attributes[:foo].value | |
assert_equal 2, duped[:foo].value | |
assert_equal "foobar", attributes[:bar].value | |
assert_equal "foobar", duped[:bar].value | |
end | |
test "deep_duping creates a new hash and dups each attribute" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) | |
attributes = builder.build_from_database(foo: 1, bar: "foo") | |
# Ensure the type cast value is cached | |
attributes[:foo].value | |
attributes[:bar].value | |
duped = attributes.deep_dup | |
duped.write_from_database(:foo, 2) | |
duped[:bar].value << "bar" | |
assert_equal 1, attributes[:foo].value | |
assert_equal 2, duped[:foo].value | |
assert_equal "foo", attributes[:bar].value | |
assert_equal "foobar", duped[:bar].value | |
end | |
test "freezing cloned set does not freeze original" do | |
attributes = AttributeSet.new({}) | |
clone = attributes.clone | |
clone.freeze | |
assert_predicate clone, :frozen? | |
assert_not_predicate attributes, :frozen? | |
end | |
test "to_hash returns a hash of the type cast values" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) | |
attributes = builder.build_from_database(foo: "1.1", bar: "2.2") | |
assert_equal({ foo: 1, bar: 2.2 }, attributes.to_hash) | |
assert_equal({ foo: 1, bar: 2.2 }, attributes.to_h) | |
end | |
test "to_hash maintains order" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) | |
attributes = builder.build_from_database(foo: "2.2", bar: "3.3") | |
attributes[:bar] | |
hash = attributes.to_h | |
assert_equal [[:foo, 2], [:bar, 3.3]], hash.to_a | |
end | |
test "values_before_type_cast" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) | |
attributes = builder.build_from_database(foo: "1.1", bar: "2.2") | |
assert_equal({ foo: "1.1", bar: "2.2" }, attributes.values_before_type_cast) | |
end | |
test "known columns are built with uninitialized attributes" do | |
attributes = attributes_with_uninitialized_key | |
assert_predicate attributes[:foo], :initialized? | |
assert_not_predicate attributes[:bar], :initialized? | |
end | |
test "uninitialized attributes are not included in the attributes hash" do | |
attributes = attributes_with_uninitialized_key | |
assert_equal({ foo: 1 }, attributes.to_hash) | |
end | |
test "uninitialized attributes are not included in keys" do | |
attributes = attributes_with_uninitialized_key | |
assert_equal [:foo], attributes.keys | |
end | |
test "uninitialized attributes return false for key?" do | |
attributes = attributes_with_uninitialized_key | |
assert attributes.key?(:foo) | |
assert_not attributes.key?(:bar) | |
end | |
test "unknown attributes return false for key?" do | |
attributes = attributes_with_uninitialized_key | |
assert_not attributes.key?(:wibble) | |
end | |
test "fetch_value returns the value for the given initialized attribute" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) | |
attributes = builder.build_from_database(foo: "1.1", bar: "2.2") | |
assert_equal 1, attributes.fetch_value(:foo) | |
assert_equal 2.2, attributes.fetch_value(:bar) | |
end | |
test "fetch_value returns nil for unknown attributes" do | |
attributes = attributes_with_uninitialized_key | |
assert_nil attributes.fetch_value(:wibble) { "hello" } | |
end | |
test "fetch_value returns nil for unknown attributes when types has a default" do | |
types = Hash.new(Type::Value.new) | |
builder = AttributeSet::Builder.new(types) | |
attributes = builder.build_from_database | |
assert_nil attributes.fetch_value(:wibble) { "hello" } | |
end | |
test "fetch_value uses the given block for uninitialized attributes" do | |
attributes = attributes_with_uninitialized_key | |
value = attributes.fetch_value(:bar) { |n| n.to_s + "!" } | |
assert_equal "bar!", value | |
end | |
test "fetch_value returns nil for uninitialized attributes if no block is given" do | |
attributes = attributes_with_uninitialized_key | |
assert_nil attributes.fetch_value(:bar) | |
end | |
test "the primary_key is always initialized" do | |
defaults = { foo: Attribute.from_user(:foo, nil, nil) } | |
builder = AttributeSet::Builder.new({ foo: Type::Integer.new }, defaults) | |
attributes = builder.build_from_database | |
assert attributes.key?(:foo) | |
assert_equal [:foo], attributes.keys | |
assert_predicate attributes[:foo], :initialized? | |
end | |
class MyType | |
def cast(value) | |
return if value.nil? | |
value + " from user" | |
end | |
def deserialize(value) | |
return if value.nil? | |
value + " from database" | |
end | |
def assert_valid_value(*) | |
end | |
end | |
test "write_from_database sets the attribute with database typecasting" do | |
builder = AttributeSet::Builder.new(foo: MyType.new) | |
attributes = builder.build_from_database | |
assert_nil attributes.fetch_value(:foo) | |
attributes.write_from_database(:foo, "value") | |
assert_equal "value from database", attributes.fetch_value(:foo) | |
end | |
test "write_from_user sets the attribute with user typecasting" do | |
builder = AttributeSet::Builder.new(foo: MyType.new) | |
attributes = builder.build_from_database | |
assert_nil attributes.fetch_value(:foo) | |
attributes.write_from_user(:foo, "value") | |
assert_equal "value from user", attributes.fetch_value(:foo) | |
end | |
class MySerializedType < ::ActiveModel::Type::Value | |
def serialize(value) | |
value + " serialized" | |
end | |
end | |
test "values_for_database" do | |
builder = AttributeSet::Builder.new(foo: MySerializedType.new) | |
attributes = builder.build_from_database | |
attributes.write_from_user(:foo, "value") | |
assert_equal({ foo: "value serialized" }, attributes.values_for_database) | |
end | |
test "freezing doesn't prevent the set from materializing" do | |
builder = AttributeSet::Builder.new(foo: Type::String.new) | |
attributes = builder.build_from_database(foo: "1") | |
attributes.freeze | |
assert_equal({ foo: "1" }, attributes.to_hash) | |
end | |
test "marshalling dump/load materialized attribute hash" do | |
builder = AttributeSet::Builder.new(foo: Type::String.new) | |
def builder.build_from_database(values = {}, additional_types = {}) | |
attributes = LazyAttributeHash.new(types, values, additional_types, default_attributes) | |
AttributeSet.new(attributes) | |
end | |
attributes = builder.build_from_database(foo: "1") | |
data = Marshal.dump(attributes) | |
attributes = Marshal.load(data) | |
assert_equal({ foo: "1" }, attributes.to_hash) | |
end | |
test "#accessed_attributes returns only attributes which have been read" do | |
builder = AttributeSet::Builder.new(foo: Type::Value.new, bar: Type::Value.new) | |
attributes = builder.build_from_database(foo: "1", bar: "2") | |
assert_equal [], attributes.accessed | |
attributes.fetch_value(:foo) | |
assert_equal [:foo], attributes.accessed | |
end | |
test "#map returns a new attribute set with the changes applied" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) | |
attributes = builder.build_from_database(foo: "1", bar: "2") | |
new_attributes = attributes.map do |attr| | |
attr.with_cast_value(attr.value + 1) | |
end | |
assert_equal 2, new_attributes.fetch_value(:foo) | |
assert_equal 3, new_attributes.fetch_value(:bar) | |
end | |
test "comparison for equality is correctly implemented" do | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) | |
attributes = builder.build_from_database(foo: "1", bar: "2") | |
attributes2 = builder.build_from_database(foo: "1", bar: "2") | |
attributes3 = builder.build_from_database(foo: "2", bar: "2") | |
assert_equal attributes, attributes2 | |
assert_not_equal attributes2, attributes3 | |
end | |
private | |
def attributes_with_uninitialized_key | |
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) | |
builder.build_from_database(foo: "1.1") | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../../abstract_unit" | |
require "active_support/core_ext/class/attribute" | |
class ClassAttributeTest < ActiveSupport::TestCase | |
def setup | |
@klass = Class.new do | |
class_attribute :setting | |
class_attribute :timeout, default: 5 | |
end | |
@sub = Class.new(@klass) | |
end | |
test "defaults to nil" do | |
assert_nil @klass.setting | |
assert_nil @sub.setting | |
end | |
test "custom default" do | |
assert_equal 5, @klass.timeout | |
end | |
test "inheritable" do | |
@klass.setting = 1 | |
assert_equal 1, @sub.setting | |
end | |
test "overridable" do | |
@sub.setting = 1 | |
assert_nil @klass.setting | |
@klass.setting = 2 | |
assert_equal 1, @sub.setting | |
assert_equal 1, Class.new(@sub).setting | |
end | |
test "predicate method" do | |
assert_equal false, @klass.setting? | |
@klass.setting = 1 | |
assert_equal true, @klass.setting? | |
end | |
test "instance reader delegates to class" do | |
assert_nil @klass.new.setting | |
@klass.setting = 1 | |
assert_equal 1, @klass.new.setting | |
end | |
test "instance override" do | |
object = @klass.new | |
object.setting = 1 | |
assert_nil @klass.setting | |
@klass.setting = 2 | |
assert_equal 1, object.setting | |
end | |
test "instance predicate" do | |
object = @klass.new | |
assert_equal false, object.setting? | |
object.setting = 1 | |
assert_equal true, object.setting? | |
end | |
test "disabling instance writer" do | |
object = Class.new { class_attribute :setting, instance_writer: false }.new | |
assert_raise(NoMethodError) { object.setting = "boom" } | |
assert_not_respond_to object, :setting= | |
end | |
test "disabling instance reader" do | |
object = Class.new { class_attribute :setting, instance_reader: false }.new | |
assert_raise(NoMethodError) { object.setting } | |
assert_not_respond_to object, :setting | |
assert_raise(NoMethodError) { object.setting? } | |
assert_not_respond_to object, :setting? | |
end | |
test "disabling both instance writer and reader" do | |
object = Class.new { class_attribute :setting, instance_accessor: false }.new | |
assert_raise(NoMethodError) { object.setting } | |
assert_not_respond_to object, :setting | |
assert_raise(NoMethodError) { object.setting? } | |
assert_not_respond_to object, :setting? | |
assert_raise(NoMethodError) { object.setting = "boom" } | |
assert_not_respond_to object, :setting= | |
end | |
test "disabling instance predicate" do | |
object = Class.new { class_attribute :setting, instance_predicate: false }.new | |
assert_raise(NoMethodError) { object.setting? } | |
assert_not_respond_to object, :setting? | |
end | |
test "works well with singleton classes" do | |
object = @klass.new | |
object.singleton_class.setting = "foo" | |
assert_equal "foo", object.setting | |
end | |
test "works well with module singleton classes" do | |
@module = Module.new do | |
class << self | |
class_attribute :settings, default: 42 | |
end | |
end | |
assert_equal 42, @module.settings | |
end | |
test "setter returns set value" do | |
val = @klass.public_send(:setting=, 1) | |
assert_equal 1, val | |
end | |
end |
# frozen_string_literal: true | |
require "active_model/attribute/user_provided_default" | |
module ActiveRecord | |
# See ActiveRecord::Attributes::ClassMethods for documentation | |
module Attributes | |
extend ActiveSupport::Concern | |
included do | |
class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false, default: {} # :internal: | |
end | |
module ClassMethods | |
# Defines an attribute with a type on this model. It will override the | |
# type of existing attributes if needed. This allows control over how | |
# values are converted to and from SQL when assigned to a model. It also | |
# changes the behavior of values passed to | |
# {ActiveRecord::Base.where}[rdoc-ref:QueryMethods#where]. This will let you use | |
# your domain objects across much of Active Record, without having to | |
# rely on implementation details or monkey patching. | |
# | |
# +name+ The name of the methods to define attribute methods for, and the | |
# column which this will persist to. | |
# | |
# +cast_type+ A symbol such as +:string+ or +:integer+, or a type object | |
# to be used for this attribute. See the examples below for more | |
# information about providing custom type objects. | |
# | |
# ==== Options | |
# | |
# The following options are accepted: | |
# | |
# +default+ The default value to use when no value is provided. If this option | |
# is not passed, the previous default value (if any) will be used. | |
# Otherwise, the default will be +nil+. | |
# | |
# +array+ (PostgreSQL only) specifies that the type should be an array (see the | |
# examples below). | |
# | |
# +range+ (PostgreSQL only) specifies that the type should be a range (see the | |
# examples below). | |
# | |
# When using a symbol for +cast_type+, extra options are forwarded to the | |
# constructor of the type object. | |
# | |
# ==== Examples | |
# | |
# The type detected by Active Record can be overridden. | |
# | |
# # db/schema.rb | |
# create_table :store_listings, force: true do |t| | |
# t.decimal :price_in_cents | |
# end | |
# | |
# # app/models/store_listing.rb | |
# class StoreListing < ActiveRecord::Base | |
# end | |
# | |
# store_listing = StoreListing.new(price_in_cents: '10.1') | |
# | |
# # before | |
# store_listing.price_in_cents # => BigDecimal(10.1) | |
# | |
# class StoreListing < ActiveRecord::Base | |
# attribute :price_in_cents, :integer | |
# end | |
# | |
# # after | |
# store_listing.price_in_cents # => 10 | |
# | |
# A default can also be provided. | |
# | |
# # db/schema.rb | |
# create_table :store_listings, force: true do |t| | |
# t.string :my_string, default: "original default" | |
# end | |
# | |
# StoreListing.new.my_string # => "original default" | |
# | |
# # app/models/store_listing.rb | |
# class StoreListing < ActiveRecord::Base | |
# attribute :my_string, :string, default: "new default" | |
# end | |
# | |
# StoreListing.new.my_string # => "new default" | |
# | |
# class Product < ActiveRecord::Base | |
# attribute :my_default_proc, :datetime, default: -> { Time.now } | |
# end | |
# | |
# Product.new.my_default_proc # => 2015-05-30 11:04:48 -0600 | |
# sleep 1 | |
# Product.new.my_default_proc # => 2015-05-30 11:04:49 -0600 | |
# | |
# \Attributes do not need to be backed by a database column. | |
# | |
# # app/models/my_model.rb | |
# class MyModel < ActiveRecord::Base | |
# attribute :my_string, :string | |
# attribute :my_int_array, :integer, array: true | |
# attribute :my_float_range, :float, range: true | |
# end | |
# | |
# model = MyModel.new( | |
# my_string: "string", | |
# my_int_array: ["1", "2", "3"], | |
# my_float_range: "[1,3.5]", | |
# ) | |
# model.attributes | |
# # => | |
# { | |
# my_string: "string", | |
# my_int_array: [1, 2, 3], | |
# my_float_range: 1.0..3.5 | |
# } | |
# | |
# Passing options to the type constructor | |
# | |
# # app/models/my_model.rb | |
# class MyModel < ActiveRecord::Base | |
# attribute :small_int, :integer, limit: 2 | |
# end | |
# | |
# MyModel.create(small_int: 65537) | |
# # => Error: 65537 is out of range for the limit of two bytes | |
# | |
# ==== Creating Custom Types | |
# | |
# Users may also define their own custom types, as long as they respond | |
# to the methods defined on the value type. The method +deserialize+ or | |
# +cast+ will be called on your type object, with raw input from the | |
# database or from your controllers. See ActiveModel::Type::Value for the | |
# expected API. It is recommended that your type objects inherit from an | |
# existing type, or from ActiveRecord::Type::Value | |
# | |
# class MoneyType < ActiveRecord::Type::Integer | |
# def cast(value) | |
# if !value.kind_of?(Numeric) && value.include?('$') | |
# price_in_dollars = value.gsub(/\$/, '').to_f | |
# super(price_in_dollars * 100) | |
# else | |
# super | |
# end | |
# end | |
# end | |
# | |
# # config/initializers/types.rb | |
# ActiveRecord::Type.register(:money, MoneyType) | |
# | |
# # app/models/store_listing.rb | |
# class StoreListing < ActiveRecord::Base | |
# attribute :price_in_cents, :money | |
# end | |
# | |
# store_listing = StoreListing.new(price_in_cents: '$10.00') | |
# store_listing.price_in_cents # => 1000 | |
# | |
# For more details on creating custom types, see the documentation for | |
# ActiveModel::Type::Value. For more details on registering your types | |
# to be referenced by a symbol, see ActiveRecord::Type.register. You can | |
# also pass a type object directly, in place of a symbol. | |
# | |
# ==== \Querying | |
# | |
# When {ActiveRecord::Base.where}[rdoc-ref:QueryMethods#where] is called, it will | |
# use the type defined by the model class to convert the value to SQL, | |
# calling +serialize+ on your type object. For example: | |
# | |
# class Money < Struct.new(:amount, :currency) | |
# end | |
# | |
# class MoneyType < ActiveRecord::Type::Value | |
# def initialize(currency_converter:) | |
# @currency_converter = currency_converter | |
# end | |
# | |
# # value will be the result of +deserialize+ or | |
# # +cast+. Assumed to be an instance of +Money+ in | |
# # this case. | |
# def serialize(value) | |
# value_in_bitcoins = @currency_converter.convert_to_bitcoins(value) | |
# value_in_bitcoins.amount | |
# end | |
# end | |
# | |
# # config/initializers/types.rb | |
# ActiveRecord::Type.register(:money, MoneyType) | |
# | |
# # app/models/product.rb | |
# class Product < ActiveRecord::Base | |
# currency_converter = ConversionRatesFromTheInternet.new | |
# attribute :price_in_bitcoins, :money, currency_converter: currency_converter | |
# end | |
# | |
# Product.where(price_in_bitcoins: Money.new(5, "USD")) | |
# # => SELECT * FROM products WHERE price_in_bitcoins = 0.02230 | |
# | |
# Product.where(price_in_bitcoins: Money.new(5, "GBP")) | |
# # => SELECT * FROM products WHERE price_in_bitcoins = 0.03412 | |
# | |
# ==== Dirty Tracking | |
# | |
# The type of an attribute is given the opportunity to change how dirty | |
# tracking is performed. The methods +changed?+ and +changed_in_place?+ | |
# will be called from ActiveModel::Dirty. See the documentation for those | |
# methods in ActiveModel::Type::Value for more details. | |
def attribute(name, cast_type = nil, default: NO_DEFAULT_PROVIDED, **options) | |
name = name.to_s | |
name = attribute_aliases[name] || name | |
reload_schema_from_cache | |
case cast_type | |
when Symbol | |
cast_type = Type.lookup(cast_type, **options, adapter: Type.adapter_name_from(self)) | |
when nil | |
if (prev_cast_type, prev_default = attributes_to_define_after_schema_loads[name]) | |
default = prev_default if default == NO_DEFAULT_PROVIDED | |
else | |
prev_cast_type = -> subtype { subtype } | |
end | |
cast_type = if block_given? | |
-> subtype { yield Proc === prev_cast_type ? prev_cast_type[subtype] : prev_cast_type } | |
else | |
prev_cast_type | |
end | |
end | |
self.attributes_to_define_after_schema_loads = | |
attributes_to_define_after_schema_loads.merge(name => [cast_type, default]) | |
end | |
# This is the low level API which sits beneath +attribute+. It only | |
# accepts type objects, and will do its work immediately instead of | |
# waiting for the schema to load. Automatic schema detection and | |
# ClassMethods#attribute both call this under the hood. While this method | |
# is provided so it can be used by plugin authors, application code | |
# should probably use ClassMethods#attribute. | |
# | |
# +name+ The name of the attribute being defined. Expected to be a +String+. | |
# | |
# +cast_type+ The type object to use for this attribute. | |
# | |
# +default+ The default value to use when no value is provided. If this option | |
# is not passed, the previous default value (if any) will be used. | |
# Otherwise, the default will be +nil+. A proc can also be passed, and | |
# will be called once each time a new value is needed. | |
# | |
# +user_provided_default+ Whether the default value should be cast using | |
# +cast+ or +deserialize+. | |
def define_attribute( | |
name, | |
cast_type, | |
default: NO_DEFAULT_PROVIDED, | |
user_provided_default: true | |
) | |
attribute_types[name] = cast_type | |
define_default_attribute(name, default, cast_type, from_user: user_provided_default) | |
end | |
def load_schema! # :nodoc: | |
super | |
attributes_to_define_after_schema_loads.each do |name, (cast_type, default)| | |
cast_type = cast_type[type_for_attribute(name)] if Proc === cast_type | |
define_attribute(name, cast_type, default: default) | |
end | |
end | |
private | |
NO_DEFAULT_PROVIDED = Object.new # :nodoc: | |
private_constant :NO_DEFAULT_PROVIDED | |
def define_default_attribute(name, value, type, from_user:) | |
if value == NO_DEFAULT_PROVIDED | |
default_attribute = _default_attributes[name].with_type(type) | |
elsif from_user | |
default_attribute = ActiveModel::Attribute::UserProvidedDefault.new( | |
name, | |
value, | |
type, | |
_default_attributes.fetch(name.to_s) { nil }, | |
) | |
else | |
default_attribute = ActiveModel::Attribute.from_database(name, value, type) | |
end | |
_default_attributes[name] = default_attribute | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
class AttributesDirtyTest < ActiveModel::TestCase | |
class DirtyModel | |
include ActiveModel::Model | |
include ActiveModel::Attributes | |
include ActiveModel::Dirty | |
attribute :name, :string | |
attribute :color, :string | |
attribute :size, :integer | |
def save | |
changes_applied | |
end | |
end | |
setup do | |
@model = DirtyModel.new | |
end | |
test "setting attribute will result in change" do | |
assert_not_predicate @model, :changed? | |
assert_not_predicate @model, :name_changed? | |
@model.name = "Ringo" | |
assert_predicate @model, :changed? | |
assert_predicate @model, :name_changed? | |
end | |
test "list of changed attribute keys" do | |
assert_equal [], @model.changed | |
@model.name = "Paul" | |
assert_equal ["name"], @model.changed | |
end | |
test "changes to attribute values" do | |
assert_not @model.changes["name"] | |
@model.name = "John" | |
assert_equal [nil, "John"], @model.changes["name"] | |
end | |
test "checking if an attribute has changed to a particular value" do | |
@model.name = "Ringo" | |
assert @model.name_changed?(from: nil, to: "Ringo") | |
assert_not @model.name_changed?(from: "Pete", to: "Ringo") | |
assert @model.name_changed?(to: "Ringo") | |
assert_not @model.name_changed?(to: "Pete") | |
assert @model.name_changed?(from: nil) | |
assert_not @model.name_changed?(from: "Pete") | |
end | |
test "changes accessible through both strings and symbols" do | |
@model.name = "David" | |
assert_not_nil @model.changes[:name] | |
assert_not_nil @model.changes["name"] | |
end | |
test "be consistent with symbols arguments after the changes are applied" do | |
@model.name = "David" | |
assert @model.attribute_changed?(:name) | |
@model.save | |
@model.name = "Rafael" | |
assert @model.attribute_changed?(:name) | |
end | |
test "attribute mutation" do | |
@model.name = "Yam" | |
@model.save | |
assert_not_predicate @model, :name_changed? | |
@model.name.replace("Hadad") | |
assert_predicate @model, :name_changed? | |
end | |
test "resetting attribute" do | |
@model.name = "Bob" | |
@model.restore_name! | |
assert_nil @model.name | |
assert_not_predicate @model, :name_changed? | |
end | |
test "setting color to same value should not result in change being recorded" do | |
@model.color = "red" | |
assert_predicate @model, :color_changed? | |
@model.save | |
assert_not_predicate @model, :color_changed? | |
assert_not_predicate @model, :changed? | |
@model.color = "red" | |
assert_not_predicate @model, :color_changed? | |
assert_not_predicate @model, :changed? | |
end | |
test "saving should reset model's changed status" do | |
@model.name = "Alf" | |
assert_predicate @model, :changed? | |
@model.save | |
assert_not_predicate @model, :changed? | |
assert_not_predicate @model, :name_changed? | |
end | |
test "saving should preserve previous changes" do | |
@model.name = "Jericho Cane" | |
@model.save | |
assert_equal [nil, "Jericho Cane"], @model.previous_changes["name"] | |
end | |
test "setting new attributes should not affect previous changes" do | |
@model.name = "Jericho Cane" | |
@model.save | |
@model.name = "DudeFella ManGuy" | |
assert_equal [nil, "Jericho Cane"], @model.name_previous_change | |
end | |
test "saving should preserve model's previous changed status" do | |
@model.name = "Jericho Cane" | |
@model.save | |
assert_predicate @model, :name_previously_changed? | |
end | |
test "previous value is preserved when changed after save" do | |
assert_equal({}, @model.changed_attributes) | |
@model.name = "Paul" | |
assert_equal({ "name" => nil }, @model.changed_attributes) | |
@model.save | |
@model.name = "John" | |
assert_equal({ "name" => "Paul" }, @model.changed_attributes) | |
end | |
test "changing the same attribute multiple times retains the correct original value" do | |
@model.name = "Otto" | |
@model.save | |
@model.name = "DudeFella ManGuy" | |
@model.name = "Mr. Manfredgensonton" | |
assert_equal ["Otto", "Mr. Manfredgensonton"], @model.name_change | |
assert_equal @model.name_was, "Otto" | |
end | |
test "using attribute_will_change! with a symbol" do | |
@model.size = 1 | |
assert_predicate @model, :size_changed? | |
end | |
test "clear_changes_information should reset all changes" do | |
@model.name = "Dmitry" | |
@model.name_changed? | |
@model.save | |
@model.name = "Bob" | |
assert_equal [nil, "Dmitry"], @model.previous_changes["name"] | |
assert_equal "Dmitry", @model.changed_attributes["name"] | |
@model.clear_changes_information | |
assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.previous_changes | |
assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.changed_attributes | |
end | |
test "restore_attributes should restore all previous data" do | |
@model.name = "Dmitry" | |
@model.color = "Red" | |
@model.save | |
@model.name = "Bob" | |
@model.color = "White" | |
@model.restore_attributes | |
assert_not_predicate @model, :changed? | |
assert_equal "Dmitry", @model.name | |
assert_equal "Red", @model.color | |
end | |
test "restore_attributes can restore only some attributes" do | |
@model.name = "Dmitry" | |
@model.color = "Red" | |
@model.save | |
@model.name = "Bob" | |
@model.color = "White" | |
@model.restore_attributes(["name"]) | |
assert_predicate @model, :changed? | |
assert_equal "Dmitry", @model.name | |
assert_equal "White", @model.color | |
end | |
test "changing the attribute reports a change only when the cast value changes" do | |
@model.size = "2.3" | |
@model.save | |
@model.size = "2.1" | |
assert_equal false, @model.changed? | |
@model.size = "5.1" | |
assert_equal true, @model.changed? | |
assert_equal true, @model.size_changed? | |
assert_equal({ "size" => [2, 5] }, @model.changes) | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
class OverloadedType < ActiveRecord::Base | |
attribute :overloaded_float, :integer | |
attribute :overloaded_string_with_limit, :string, limit: 50 | |
attribute :non_existent_decimal, :decimal | |
attribute :string_with_default, :string, default: "the overloaded default" | |
end | |
class ChildOfOverloadedType < OverloadedType | |
end | |
class GrandchildOfOverloadedType < ChildOfOverloadedType | |
attribute :overloaded_float, :float | |
end | |
class UnoverloadedType < ActiveRecord::Base | |
self.table_name = "overloaded_types" | |
end | |
module ActiveRecord | |
class CustomPropertiesTest < ActiveRecord::TestCase | |
test "overloading types" do | |
data = OverloadedType.new | |
data.overloaded_float = "1.1" | |
data.unoverloaded_float = "1.1" | |
assert_equal 1, data.overloaded_float | |
assert_equal 1.1, data.unoverloaded_float | |
end | |
test "overloaded properties save" do | |
data = OverloadedType.new | |
data.overloaded_float = "2.2" | |
data.save! | |
data.reload | |
assert_equal 2, data.overloaded_float | |
assert_kind_of Integer, OverloadedType.last.overloaded_float | |
assert_equal 2.0, UnoverloadedType.last.overloaded_float | |
assert_kind_of Float, UnoverloadedType.last.overloaded_float | |
end | |
test "properties assigned in constructor" do | |
data = OverloadedType.new(overloaded_float: "3.3") | |
assert_equal 3, data.overloaded_float | |
end | |
test "overloaded properties with limit" do | |
assert_equal 50, OverloadedType.type_for_attribute("overloaded_string_with_limit").limit | |
assert_equal 255, UnoverloadedType.type_for_attribute("overloaded_string_with_limit").limit | |
end | |
test "overloaded default but keeping its own type" do | |
klass = Class.new(UnoverloadedType) do | |
attribute :overloaded_string_with_limit, default: "the overloaded default" | |
end | |
assert_equal 255, UnoverloadedType.type_for_attribute("overloaded_string_with_limit").limit | |
assert_equal 255, klass.type_for_attribute("overloaded_string_with_limit").limit | |
assert_nil UnoverloadedType.new.overloaded_string_with_limit | |
assert_equal "the overloaded default", klass.new.overloaded_string_with_limit | |
end | |
test "attributes with overridden types keep their type when a default value is configured separately" do | |
child = Class.new(OverloadedType) do | |
attribute :overloaded_float, default: "123" | |
end | |
assert_equal OverloadedType.type_for_attribute("overloaded_float"), child.type_for_attribute("overloaded_float") | |
assert_equal 123, child.new.overloaded_float | |
end | |
test "extra options are forwarded to the type caster constructor" do | |
klass = Class.new(OverloadedType) do | |
attribute :starts_at, :datetime, precision: 3, limit: 2, scale: 1, default: -> { Time.now.utc } | |
end | |
starts_at_type = klass.type_for_attribute(:starts_at) | |
assert_equal 3, starts_at_type.precision | |
assert_equal 2, starts_at_type.limit | |
assert_equal 1, starts_at_type.scale | |
assert_kind_of Type::DateTime, starts_at_type | |
assert_instance_of Time, klass.new.starts_at | |
end | |
test "time zone aware attribute" do | |
with_timezone_config aware_attributes: true, zone: "Pacific Time (US & Canada)" do | |
klass = Class.new(OverloadedType) do | |
attribute :starts_at, :datetime, precision: 3, default: -> { Time.now.utc } | |
attribute :ends_at, default: -> { Time.now.utc } | |
end | |
starts_at_type = klass.type_for_attribute(:starts_at) | |
ends_at_type = klass.type_for_attribute(:ends_at) | |
assert_instance_of AttributeMethods::TimeZoneConversion::TimeZoneConverter, starts_at_type | |
assert_instance_of AttributeMethods::TimeZoneConversion::TimeZoneConverter, ends_at_type | |
assert_kind_of Type::DateTime, starts_at_type.__getobj__ | |
assert_kind_of Type::DateTime, ends_at_type.__getobj__ | |
assert_instance_of ActiveSupport::TimeWithZone, klass.new.starts_at | |
assert_instance_of ActiveSupport::TimeWithZone, klass.new.ends_at | |
end | |
end | |
test "nonexistent attribute" do | |
data = OverloadedType.new(non_existent_decimal: 1) | |
assert_equal BigDecimal(1), data.non_existent_decimal | |
assert_raise ActiveRecord::UnknownAttributeError do | |
UnoverloadedType.new(non_existent_decimal: 1) | |
end | |
end | |
test "model with nonexistent attribute with default value can be saved" do | |
klass = Class.new(OverloadedType) do | |
attribute :non_existent_string_with_default, :string, default: "nonexistent" | |
end | |
model = klass.new | |
assert model.save | |
end | |
test "changing defaults" do | |
data = OverloadedType.new | |
unoverloaded_data = UnoverloadedType.new | |
assert_equal "the overloaded default", data.string_with_default | |
assert_equal "the original default", unoverloaded_data.string_with_default | |
end | |
test "defaults are not touched on the columns" do | |
assert_equal "the original default", OverloadedType.columns_hash["string_with_default"].default | |
end | |
test "children inherit custom properties" do | |
data = ChildOfOverloadedType.new(overloaded_float: "4.4") | |
assert_equal 4, data.overloaded_float | |
end | |
test "children can override parents" do | |
data = GrandchildOfOverloadedType.new(overloaded_float: "4.4") | |
assert_equal 4.4, data.overloaded_float | |
end | |
test "overloading properties does not attribute method order" do | |
attribute_names = OverloadedType.attribute_names | |
expected = OverloadedType.column_names + ["non_existent_decimal"] | |
assert_equal expected, attribute_names | |
end | |
test "caches are cleared" do | |
klass = Class.new(OverloadedType) | |
column_count = klass.columns.length | |
assert_equal column_count + 1, klass.attribute_types.length | |
assert_equal column_count + 1, klass.column_defaults.length | |
assert_equal column_count + 1, klass.attribute_names.length | |
assert_not klass.attribute_types.include?("wibble") | |
klass.attribute :wibble, Type::Value.new | |
assert_equal column_count + 2, klass.attribute_types.length | |
assert_equal column_count + 2, klass.column_defaults.length | |
assert_equal column_count + 2, klass.attribute_names.length | |
assert_includes klass.attribute_types, "wibble" | |
end | |
test "the given default value is cast from user" do | |
custom_type = Class.new(Type::Value) do | |
def cast(*) | |
"from user" | |
end | |
def deserialize(*) | |
"from database" | |
end | |
end | |
klass = Class.new(OverloadedType) do | |
attribute :wibble, custom_type.new, default: "default" | |
end | |
model = klass.new | |
assert_equal "from user", model.wibble | |
end | |
test "procs for default values" do | |
klass = Class.new(OverloadedType) do | |
@@counter = 0 | |
attribute :counter, :integer, default: -> { @@counter += 1 } | |
end | |
assert_equal 1, klass.new.counter | |
assert_equal 2, klass.new.counter | |
end | |
test "procs for default values are evaluated even after column_defaults is called" do | |
klass = Class.new(OverloadedType) do | |
@@counter = 0 | |
attribute :counter, :integer, default: -> { @@counter += 1 } | |
end | |
assert_equal 1, klass.new.counter | |
# column_defaults will increment the counter since the proc is called | |
klass.column_defaults | |
assert_equal 3, klass.new.counter | |
end | |
test "procs are memoized before type casting" do | |
klass = Class.new(OverloadedType) do | |
@@counter = 0 | |
attribute :counter, :integer, default: -> { @@counter += 1 } | |
end | |
model = klass.new | |
assert_equal 1, model.counter_before_type_cast | |
assert_equal 1, model.counter_before_type_cast | |
end | |
test "user provided defaults are persisted even if unchanged" do | |
model = OverloadedType.create! | |
assert_equal "the overloaded default", model.reload.string_with_default | |
end | |
if current_adapter?(:PostgreSQLAdapter) | |
test "array types can be specified" do | |
klass = Class.new(OverloadedType) do | |
attribute :my_array, :string, limit: 50, array: true | |
attribute :my_int_array, :integer, array: true | |
end | |
string_array = ConnectionAdapters::PostgreSQL::OID::Array.new( | |
Type::String.new(limit: 50)) | |
int_array = ConnectionAdapters::PostgreSQL::OID::Array.new( | |
Type::Integer.new) | |
assert_not_equal string_array, int_array | |
assert_equal string_array, klass.type_for_attribute("my_array") | |
assert_equal int_array, klass.type_for_attribute("my_int_array") | |
end | |
test "range types can be specified" do | |
klass = Class.new(OverloadedType) do | |
attribute :my_range, :string, limit: 50, range: true | |
attribute :my_int_range, :integer, range: true | |
end | |
string_range = ConnectionAdapters::PostgreSQL::OID::Range.new( | |
Type::String.new(limit: 50)) | |
int_range = ConnectionAdapters::PostgreSQL::OID::Range.new( | |
Type::Integer.new) | |
assert_not_equal string_range, int_range | |
assert_equal string_range, klass.type_for_attribute("my_range") | |
assert_equal int_range, klass.type_for_attribute("my_int_range") | |
end | |
end | |
test "attributes added after subclasses load are inherited" do | |
parent = Class.new(ActiveRecord::Base) do | |
self.table_name = "topics" | |
end | |
child = Class.new(parent) | |
child.new # => force a schema load | |
parent.attribute(:foo, Type::Value.new) | |
assert_equal(:bar, child.new(foo: :bar).foo) | |
end | |
test "attributes not backed by database columns are not dirty when unchanged" do | |
assert_not_predicate OverloadedType.new, :non_existent_decimal_changed? | |
end | |
test "attributes not backed by database columns are always initialized" do | |
OverloadedType.create! | |
model = OverloadedType.last | |
assert_nil model.non_existent_decimal | |
model.non_existent_decimal = "123" | |
assert_equal 123, model.non_existent_decimal | |
end | |
test "attributes not backed by database columns return the default on models loaded from database" do | |
child = Class.new(OverloadedType) do | |
attribute :non_existent_decimal, :decimal, default: 123 | |
end | |
child.create! | |
model = child.last | |
assert_equal 123, model.non_existent_decimal | |
end | |
test "attributes not backed by database columns keep their type when a default value is configured separately" do | |
child = Class.new(OverloadedType) do | |
attribute :non_existent_decimal, default: "123" | |
end | |
assert_equal OverloadedType.type_for_attribute("non_existent_decimal"), child.type_for_attribute("non_existent_decimal") | |
assert_equal 123, child.new.non_existent_decimal | |
end | |
test "attributes not backed by database columns properly interact with mutation and dirty" do | |
child = Class.new(ActiveRecord::Base) do | |
self.table_name = "topics" | |
attribute :foo, :string, default: "lol" | |
end | |
child.create! | |
model = child.last | |
assert_equal "lol", model.foo | |
model.foo << "asdf" | |
assert_equal "lolasdf", model.foo | |
assert_predicate model, :foo_changed? | |
model.reload | |
assert_equal "lol", model.foo | |
model.foo = "lol" | |
assert_not_predicate model, :changed? | |
end | |
test "attributes not backed by database columns appear in inspect" do | |
inspection = OverloadedType.new.inspect | |
assert_includes inspection, "non_existent_decimal" | |
end | |
test "attributes do not require a type" do | |
klass = Class.new(OverloadedType) do | |
attribute :no_type | |
end | |
assert_equal 1, klass.new(no_type: 1).no_type | |
assert_equal "foo", klass.new(no_type: "foo").no_type | |
end | |
test "attributes do not require a connection is established" do | |
assert_not_called(ActiveRecord::Base, :connection) do | |
Class.new(OverloadedType) do | |
attribute :foo, :string | |
end | |
end | |
end | |
test "unknown type error is raised" do | |
assert_raise(ArgumentError) do | |
OverloadedType.attribute :foo, :unknown | |
end | |
end | |
test "immutable_strings_by_default changes schema inference for string columns" do | |
with_immutable_strings do | |
OverloadedType.reset_column_information | |
immutable_string_type = Type.lookup(:immutable_string).class | |
assert_instance_of immutable_string_type, OverloadedType.type_for_attribute("inferred_string") | |
end | |
end | |
test "immutable_strings_by_default retains limit information" do | |
with_immutable_strings do | |
OverloadedType.reset_column_information | |
assert_equal 255, OverloadedType.type_for_attribute("inferred_string").limit | |
end | |
end | |
test "immutable_strings_by_default does not affect `attribute :foo, :string`" do | |
with_immutable_strings do | |
OverloadedType.reset_column_information | |
default_string_type = Type.lookup(:string).class | |
assert_instance_of default_string_type, OverloadedType.type_for_attribute("string_with_default") | |
end | |
end | |
test "serialize boolean for both string types" do | |
default_string_type = Type.lookup(:string) | |
immutable_string_type = Type.lookup(:immutable_string) | |
assert_equal default_string_type.serialize(true), immutable_string_type.serialize(true) | |
assert_equal default_string_type.serialize(false), immutable_string_type.serialize(false) | |
end | |
private | |
def with_immutable_strings | |
old_value = ActiveRecord::Base.immutable_strings_by_default | |
ActiveRecord::Base.immutable_strings_by_default = true | |
yield | |
ensure | |
ActiveRecord::Base.immutable_strings_by_default = old_value | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveStorage | |
# Extracts duration (seconds) and bit_rate (bits/s) from an audio blob. | |
# | |
# Example: | |
# | |
# ActiveStorage::Analyzer::AudioAnalyzer.new(blob).metadata | |
# # => { duration: 5.0, bit_rate: 320340 } | |
# | |
# This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails. | |
class Analyzer::AudioAnalyzer < Analyzer | |
def self.accept?(blob) | |
blob.audio? | |
end | |
def metadata | |
{ duration: duration, bit_rate: bit_rate }.compact | |
end | |
private | |
def duration | |
duration = audio_stream["duration"] | |
Float(duration) if duration | |
end | |
def bit_rate | |
bit_rate = audio_stream["bit_rate"] | |
Integer(bit_rate) if bit_rate | |
end | |
def audio_stream | |
@audio_stream ||= streams.detect { |stream| stream["codec_type"] == "audio" } || {} | |
end | |
def streams | |
probe["streams"] || [] | |
end | |
def probe | |
@probe ||= download_blob_to_tempfile { |file| probe_from(file) } | |
end | |
def probe_from(file) | |
instrument(File.basename(ffprobe_path)) do | |
IO.popen([ ffprobe_path, | |
"-print_format", "json", | |
"-show_streams", | |
"-show_format", | |
"-v", "error", | |
file.path | |
]) do |output| | |
JSON.parse(output.read) | |
end | |
end | |
rescue Errno::ENOENT | |
logger.info "Skipping audio analysis because ffprobe isn't installed" | |
{} | |
end | |
def ffprobe_path | |
ActiveStorage.paths[:ffprobe] || "ffprobe" | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "test_helper" | |
require "database/setup" | |
require "active_storage/analyzer/audio_analyzer" | |
class ActiveStorage::Analyzer::AudioAnalyzerTest < ActiveSupport::TestCase | |
test "analyzing an audio" do | |
blob = create_file_blob(filename: "audio.mp3", content_type: "audio/mp3") | |
metadata = extract_metadata_from(blob) | |
assert_equal 0.914286, metadata[:duration] | |
assert_equal 128000, metadata[:bit_rate] | |
end | |
test "instrumenting analysis" do | |
events = subscribe_events_from("analyze.active_storage") | |
blob = create_file_blob(filename: "audio.mp3", content_type: "audio/mp3") | |
blob.analyze | |
assert_equal 1, events.size | |
assert_equal({ analyzer: "ffprobe" }, events.first.payload) | |
end | |
end |
# frozen_string_literal: true | |
class AutoId < ActiveRecord::Base | |
self.table_name = "auto_id_tests" | |
self.primary_key = "auto_id" | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "support/schema_dumping_helper" | |
class Mysql2AutoIncrementTest < ActiveRecord::Mysql2TestCase | |
include SchemaDumpingHelper | |
def setup | |
@connection = ActiveRecord::Base.connection | |
end | |
def teardown | |
@connection.drop_table :auto_increments, if_exists: true | |
end | |
def test_auto_increment_without_primary_key | |
@connection.create_table :auto_increments, id: false, force: true do |t| | |
t.integer :id, null: false, auto_increment: true | |
t.index :id | |
end | |
output = dump_table_schema("auto_increments") | |
assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output) | |
end | |
def test_auto_increment_with_composite_primary_key | |
@connection.create_table :auto_increments, primary_key: [:id, :created_at], force: true do |t| | |
t.integer :id, null: false, auto_increment: true | |
t.datetime :created_at, null: false | |
end | |
output = dump_table_schema("auto_increments") | |
assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output) | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/inflector/methods" | |
module ActiveSupport | |
# Autoload and eager load conveniences for your library. | |
# | |
# This module allows you to define autoloads based on | |
# Rails conventions (i.e. no need to define the path | |
# it is automatically guessed based on the filename) | |
# and also define a set of constants that needs to be | |
# eager loaded: | |
# | |
# module MyLib | |
# extend ActiveSupport::Autoload | |
# | |
# autoload :Model | |
# | |
# eager_autoload do | |
# autoload :Cache | |
# end | |
# end | |
# | |
# Then your library can be eager loaded by simply calling: | |
# | |
# MyLib.eager_load! | |
module Autoload | |
def self.extended(base) # :nodoc: | |
if RUBY_VERSION < "3" | |
base.class_eval do | |
@_autoloads = nil | |
@_under_path = nil | |
@_at_path = nil | |
@_eager_autoload = false | |
end | |
end | |
end | |
def autoload(const_name, path = @_at_path) | |
unless path | |
full = [name, @_under_path, const_name.to_s].compact.join("::") | |
path = Inflector.underscore(full) | |
end | |
if @_eager_autoload | |
@_eagerloaded_constants ||= [] | |
@_eagerloaded_constants << const_name | |
end | |
super const_name, path | |
end | |
def autoload_under(path) | |
@_under_path, old_path = path, @_under_path | |
yield | |
ensure | |
@_under_path = old_path | |
end | |
def autoload_at(path) | |
@_at_path, old_path = path, @_at_path | |
yield | |
ensure | |
@_at_path = old_path | |
end | |
def eager_autoload | |
old_eager, @_eager_autoload = @_eager_autoload, true | |
yield | |
ensure | |
@_eager_autoload = old_eager | |
end | |
def eager_load! | |
if @_eagerloaded_constants | |
@_eagerloaded_constants.each { |const_name| const_get(const_name) } | |
@_eagerloaded_constants = nil | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "abstract_unit" | |
class TestAutoloadModule < ActiveSupport::TestCase | |
include ActiveSupport::Testing::Isolation | |
module ::Fixtures | |
extend ActiveSupport::Autoload | |
module Autoload | |
extend ActiveSupport::Autoload | |
end | |
end | |
def setup | |
@some_class_path = File.expand_path("test/fixtures/autoload/some_class.rb") | |
@another_class_path = File.expand_path("test/fixtures/autoload/another_class.rb") | |
$LOAD_PATH << "test" | |
end | |
def teardown | |
$LOAD_PATH.pop | |
end | |
test "the autoload module works like normal autoload" do | |
module ::Fixtures::Autoload | |
autoload :SomeClass, "fixtures/autoload/some_class" | |
end | |
assert_nothing_raised { ::Fixtures::Autoload::SomeClass } | |
end | |
test "when specifying an :eager constant it still works like normal autoload by default" do | |
module ::Fixtures::Autoload | |
eager_autoload do | |
autoload :SomeClass, "fixtures/autoload/some_class" | |
end | |
end | |
assert_not_includes $LOADED_FEATURES, @some_class_path | |
assert_nothing_raised { ::Fixtures::Autoload::SomeClass } | |
end | |
test "the location of autoloaded constants defaults to :name.underscore" do | |
module ::Fixtures::Autoload | |
autoload :SomeClass | |
end | |
assert_not_includes $LOADED_FEATURES, @some_class_path | |
assert_nothing_raised { ::Fixtures::Autoload::SomeClass } | |
end | |
test "the location of :eager autoloaded constants defaults to :name.underscore" do | |
module ::Fixtures::Autoload | |
eager_autoload do | |
autoload :SomeClass | |
end | |
end | |
assert_not_includes $LOADED_FEATURES, @some_class_path | |
::Fixtures::Autoload.eager_load! | |
assert_includes $LOADED_FEATURES, @some_class_path | |
assert_nothing_raised { ::Fixtures::Autoload::SomeClass } | |
end | |
test "a directory for a block of autoloads can be specified" do | |
module ::Fixtures | |
autoload_under "autoload" do | |
autoload :AnotherClass | |
end | |
end | |
assert_not_includes $LOADED_FEATURES, @another_class_path | |
assert_nothing_raised { ::Fixtures::AnotherClass } | |
end | |
test "a path for a block of autoloads can be specified" do | |
module ::Fixtures | |
autoload_at "fixtures/autoload/another_class" do | |
autoload :AnotherClass | |
end | |
end | |
assert_not_includes $LOADED_FEATURES, @another_class_path | |
assert_nothing_raised { ::Fixtures::AnotherClass } | |
end | |
end |
# frozen_string_literal: true | |
gem "minitest" | |
require "minitest" | |
Minitest.autorun |
# frozen_string_literal: true | |
module ActiveRecord | |
# = Active Record Autosave Association | |
# | |
# AutosaveAssociation is a module that takes care of automatically saving | |
# associated records when their parent is saved. In addition to saving, it | |
# also destroys any associated records that were marked for destruction. | |
# (See #mark_for_destruction and #marked_for_destruction?). | |
# | |
# Saving of the parent, its associations, and the destruction of marked | |
# associations, all happen inside a transaction. This should never leave the | |
# database in an inconsistent state. | |
# | |
# If validations for any of the associations fail, their error messages will | |
# be applied to the parent. | |
# | |
# Note that it also means that associations marked for destruction won't | |
# be destroyed directly. They will however still be marked for destruction. | |
# | |
# Note that <tt>autosave: false</tt> is not same as not declaring <tt>:autosave</tt>. | |
# When the <tt>:autosave</tt> option is not present then new association records are | |
# saved but the updated association records are not saved. | |
# | |
# == Validation | |
# | |
# Child records are validated unless <tt>:validate</tt> is +false+. | |
# | |
# == Callbacks | |
# | |
# Association with autosave option defines several callbacks on your | |
# model (around_save, before_save, after_create, after_update). Please note that | |
# callbacks are executed in the order they were defined in | |
# model. You should avoid modifying the association content before | |
# autosave callbacks are executed. Placing your callbacks after | |
# associations is usually a good practice. | |
# | |
# === One-to-one Example | |
# | |
# class Post < ActiveRecord::Base | |
# has_one :author, autosave: true | |
# end | |
# | |
# Saving changes to the parent and its associated model can now be performed | |
# automatically _and_ atomically: | |
# | |
# post = Post.find(1) | |
# post.title # => "The current global position of migrating ducks" | |
# post.author.name # => "alloy" | |
# | |
# post.title = "On the migration of ducks" | |
# post.author.name = "Eloy Duran" | |
# | |
# post.save | |
# post.reload | |
# post.title # => "On the migration of ducks" | |
# post.author.name # => "Eloy Duran" | |
# | |
# Destroying an associated model, as part of the parent's save action, is as | |
# simple as marking it for destruction: | |
# | |
# post.author.mark_for_destruction | |
# post.author.marked_for_destruction? # => true | |
# | |
# Note that the model is _not_ yet removed from the database: | |
# | |
# id = post.author.id | |
# Author.find_by(id: id).nil? # => false | |
# | |
# post.save | |
# post.reload.author # => nil | |
# | |
# Now it _is_ removed from the database: | |
# | |
# Author.find_by(id: id).nil? # => true | |
# | |
# === One-to-many Example | |
# | |
# When <tt>:autosave</tt> is not declared new children are saved when their parent is saved: | |
# | |
# class Post < ActiveRecord::Base | |
# has_many :comments # :autosave option is not declared | |
# end | |
# | |
# post = Post.new(title: 'ruby rocks') | |
# post.comments.build(body: 'hello world') | |
# post.save # => saves both post and comment | |
# | |
# post = Post.create(title: 'ruby rocks') | |
# post.comments.build(body: 'hello world') | |
# post.save # => saves both post and comment | |
# | |
# post = Post.create(title: 'ruby rocks') | |
# comment = post.comments.create(body: 'hello world') | |
# comment.body = 'hi everyone' | |
# post.save # => saves post, but not comment | |
# | |
# When <tt>:autosave</tt> is true all children are saved, no matter whether they | |
# are new records or not: | |
# | |
# class Post < ActiveRecord::Base | |
# has_many :comments, autosave: true | |
# end | |
# | |
# post = Post.create(title: 'ruby rocks') | |
# comment = post.comments.create(body: 'hello world') | |
# comment.body = 'hi everyone' | |
# post.comments.build(body: "good morning.") | |
# post.save # => saves post and both comments. | |
# | |
# Destroying one of the associated models as part of the parent's save action | |
# is as simple as marking it for destruction: | |
# | |
# post.comments # => [#<Comment id: 1, ...>, #<Comment id: 2, ...]> | |
# post.comments[1].mark_for_destruction | |
# post.comments[1].marked_for_destruction? # => true | |
# post.comments.length # => 2 | |
# | |
# Note that the model is _not_ yet removed from the database: | |
# | |
# id = post.comments.last.id | |
# Comment.find_by(id: id).nil? # => false | |
# | |
# post.save | |
# post.reload.comments.length # => 1 | |
# | |
# Now it _is_ removed from the database: | |
# | |
# Comment.find_by(id: id).nil? # => true | |
# | |
# === Caveats | |
# | |
# Note that autosave will only trigger for already-persisted association records | |
# if the records themselves have been changed. This is to protect against | |
# <tt>SystemStackError</tt> caused by circular association validations. The one | |
# exception is if a custom validation context is used, in which case the validations | |
# will always fire on the associated records. | |
module AutosaveAssociation | |
extend ActiveSupport::Concern | |
module AssociationBuilderExtension # :nodoc: | |
def self.build(model, reflection) | |
model.send(:add_autosave_association_callbacks, reflection) | |
end | |
def self.valid_options | |
[ :autosave ] | |
end | |
end | |
included do | |
Associations::Builder::Association.extensions << AssociationBuilderExtension | |
end | |
module ClassMethods # :nodoc: | |
private | |
def define_non_cyclic_method(name, &block) | |
return if method_defined?(name, false) | |
define_method(name) do |*args| | |
result = true; @_already_called ||= {} | |
# Loop prevention for validation of associations | |
unless @_already_called[name] | |
begin | |
@_already_called[name] = true | |
result = instance_eval(&block) | |
ensure | |
@_already_called[name] = false | |
end | |
end | |
result | |
end | |
end | |
# Adds validation and save callbacks for the association as specified by | |
# the +reflection+. | |
# | |
# For performance reasons, we don't check whether to validate at runtime. | |
# However the validation and callback methods are lazy and those methods | |
# get created when they are invoked for the very first time. However, | |
# this can change, for instance, when using nested attributes, which is | |
# called _after_ the association has been defined. Since we don't want | |
# the callbacks to get defined multiple times, there are guards that | |
# check if the save or validation methods have already been defined | |
# before actually defining them. | |
def add_autosave_association_callbacks(reflection) | |
save_method = :"autosave_associated_records_for_#{reflection.name}" | |
if reflection.collection? | |
around_save :around_save_collection_association | |
define_non_cyclic_method(save_method) { save_collection_association(reflection) } | |
# Doesn't use after_save as that would save associations added in after_create/after_update twice | |
after_create save_method | |
after_update save_method | |
elsif reflection.has_one? | |
define_non_cyclic_method(save_method) { save_has_one_association(reflection) } | |
# Configures two callbacks instead of a single after_save so that | |
# the model may rely on their execution order relative to its | |
# own callbacks. | |
# | |
# For example, given that after_creates run before after_saves, if | |
# we configured instead an after_save there would be no way to fire | |
# a custom after_create callback after the child association gets | |
# created. | |
after_create save_method | |
after_update save_method | |
else | |
define_non_cyclic_method(save_method) { throw(:abort) if save_belongs_to_association(reflection) == false } | |
before_save save_method | |
end | |
define_autosave_validation_callbacks(reflection) | |
end | |
def define_autosave_validation_callbacks(reflection) | |
validation_method = :"validate_associated_records_for_#{reflection.name}" | |
if reflection.validate? && !method_defined?(validation_method) | |
if reflection.collection? | |
method = :validate_collection_association | |
else | |
method = :validate_single_association | |
end | |
define_non_cyclic_method(validation_method) { send(method, reflection) } | |
validate validation_method | |
after_validation :_ensure_no_duplicate_errors | |
end | |
end | |
end | |
# Reloads the attributes of the object as usual and clears <tt>marked_for_destruction</tt> flag. | |
def reload(options = nil) | |
@marked_for_destruction = false | |
@destroyed_by_association = nil | |
super | |
end | |
# Marks this record to be destroyed as part of the parent's save transaction. | |
# This does _not_ actually destroy the record instantly, rather child record will be destroyed | |
# when <tt>parent.save</tt> is called. | |
# | |
# Only useful if the <tt>:autosave</tt> option on the parent is enabled for this associated model. | |
def mark_for_destruction | |
@marked_for_destruction = true | |
end | |
# Returns whether or not this record will be destroyed as part of the parent's save transaction. | |
# | |
# Only useful if the <tt>:autosave</tt> option on the parent is enabled for this associated model. | |
def marked_for_destruction? | |
@marked_for_destruction | |
end | |
# Records the association that is being destroyed and destroying this | |
# record in the process. | |
def destroyed_by_association=(reflection) | |
@destroyed_by_association = reflection | |
end | |
# Returns the association for the parent being destroyed. | |
# | |
# Used to avoid updating the counter cache unnecessarily. | |
def destroyed_by_association | |
@destroyed_by_association | |
end | |
# Returns whether or not this record has been changed in any way (including whether | |
# any of its nested autosave associations are likewise changed) | |
def changed_for_autosave? | |
new_record? || has_changes_to_save? || marked_for_destruction? || nested_records_changed_for_autosave? | |
end | |
private | |
# Returns the record for an association collection that should be validated | |
# or saved. If +autosave+ is +false+ only new records will be returned, | |
# unless the parent is/was a new record itself. | |
def associated_records_to_validate_or_save(association, new_record, autosave) | |
if new_record || custom_validation_context? | |
association && association.target | |
elsif autosave | |
association.target.find_all(&:changed_for_autosave?) | |
else | |
association.target.find_all(&:new_record?) | |
end | |
end | |
# Go through nested autosave associations that are loaded in memory (without loading | |
# any new ones), and return true if any are changed for autosave. | |
# Returns false if already called to prevent an infinite loop. | |
def nested_records_changed_for_autosave? | |
@_nested_records_changed_for_autosave_already_called ||= false | |
return false if @_nested_records_changed_for_autosave_already_called | |
begin | |
@_nested_records_changed_for_autosave_already_called = true | |
self.class._reflections.values.any? do |reflection| | |
if reflection.options[:autosave] | |
association = association_instance_get(reflection.name) | |
association && Array.wrap(association.target).any?(&:changed_for_autosave?) | |
end | |
end | |
ensure | |
@_nested_records_changed_for_autosave_already_called = false | |
end | |
end | |
# Validate the association if <tt>:validate</tt> or <tt>:autosave</tt> is | |
# turned on for the association. | |
def validate_single_association(reflection) | |
association = association_instance_get(reflection.name) | |
record = association && association.reader | |
association_valid?(reflection, record) if record && (record.changed_for_autosave? || custom_validation_context?) | |
end | |
# Validate the associated records if <tt>:validate</tt> or | |
# <tt>:autosave</tt> is turned on for the association specified by | |
# +reflection+. | |
def validate_collection_association(reflection) | |
if association = association_instance_get(reflection.name) | |
if records = associated_records_to_validate_or_save(association, new_record?, reflection.options[:autosave]) | |
records.each_with_index { |record, index| association_valid?(reflection, record, index) } | |
end | |
end | |
end | |
# Returns whether or not the association is valid and applies any errors to | |
# the parent, <tt>self</tt>, if it wasn't. Skips any <tt>:autosave</tt> | |
# enabled records if they're marked_for_destruction? or destroyed. | |
def association_valid?(reflection, record, index = nil) | |
return true if record.destroyed? || (reflection.options[:autosave] && record.marked_for_destruction?) | |
context = validation_context if custom_validation_context? | |
unless valid = record.valid?(context) | |
if reflection.options[:autosave] | |
indexed_attribute = !index.nil? && (reflection.options[:index_errors] || ActiveRecord.index_nested_attribute_errors) | |
record.errors.group_by_attribute.each { |attribute, errors| | |
attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute) | |
errors.each { |error| | |
self.errors.import( | |
error, | |
attribute: attribute | |
) | |
} | |
} | |
else | |
errors.add(reflection.name) | |
end | |
end | |
valid | |
end | |
def normalize_reflection_attribute(indexed_attribute, reflection, index, attribute) | |
if indexed_attribute | |
"#{reflection.name}[#{index}].#{attribute}" | |
else | |
"#{reflection.name}.#{attribute}" | |
end | |
end | |
# Is used as an around_save callback to check while saving a collection | |
# association whether or not the parent was a new record before saving. | |
def around_save_collection_association | |
previously_new_record_before_save = (@new_record_before_save ||= false) | |
@new_record_before_save = !previously_new_record_before_save && new_record? | |
yield | |
ensure | |
@new_record_before_save = previously_new_record_before_save | |
end | |
# Saves any new associated records, or all loaded autosave associations if | |
# <tt>:autosave</tt> is enabled on the association. | |
# | |
# In addition, it destroys all children that were marked for destruction | |
# with #mark_for_destruction. | |
# | |
# This all happens inside a transaction, _if_ the Transactions module is included into | |
# ActiveRecord::Base after the AutosaveAssociation module, which it does by default. | |
def save_collection_association(reflection) | |
if association = association_instance_get(reflection.name) | |
autosave = reflection.options[:autosave] | |
# By saving the instance variable in a local variable, | |
# we make the whole callback re-entrant. | |
new_record_before_save = @new_record_before_save | |
# reconstruct the scope now that we know the owner's id | |
association.reset_scope | |
if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave) | |
if autosave | |
records_to_destroy = records.select(&:marked_for_destruction?) | |
records_to_destroy.each { |record| association.destroy(record) } | |
records -= records_to_destroy | |
end | |
records.each do |record| | |
next if record.destroyed? | |
saved = true | |
if autosave != false && (new_record_before_save || record.new_record?) | |
association.set_inverse_instance(record) | |
if autosave | |
saved = association.insert_record(record, false) | |
elsif !reflection.nested? | |
association_saved = association.insert_record(record) | |
if reflection.validate? | |
errors.add(reflection.name) unless association_saved | |
saved = association_saved | |
end | |
end | |
elsif autosave | |
saved = record.save(validate: false) | |
end | |
raise(RecordInvalid.new(association.owner)) unless saved | |
end | |
end | |
end | |
end | |
# Saves the associated record if it's new or <tt>:autosave</tt> is enabled | |
# on the association. | |
# | |
# In addition, it will destroy the association if it was marked for | |
# destruction with #mark_for_destruction. | |
# | |
# This all happens inside a transaction, _if_ the Transactions module is included into | |
# ActiveRecord::Base after the AutosaveAssociation module, which it does by default. | |
def save_has_one_association(reflection) | |
association = association_instance_get(reflection.name) | |
record = association && association.load_target | |
if record && !record.destroyed? | |
autosave = reflection.options[:autosave] | |
if autosave && record.marked_for_destruction? | |
record.destroy | |
elsif autosave != false | |
key = reflection.options[:primary_key] ? public_send(reflection.options[:primary_key]) : id | |
if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, key) | |
unless reflection.through_reflection | |
record[reflection.foreign_key] = key | |
association.set_inverse_instance(record) | |
end | |
saved = record.save(validate: !autosave) | |
raise ActiveRecord::Rollback if !saved && autosave | |
saved | |
end | |
end | |
end | |
end | |
# If the record is new or it has changed, returns true. | |
def _record_changed?(reflection, record, key) | |
record.new_record? || | |
association_foreign_key_changed?(reflection, record, key) || | |
record.will_save_change_to_attribute?(reflection.foreign_key) | |
end | |
def association_foreign_key_changed?(reflection, record, key) | |
return false if reflection.through_reflection? | |
record._has_attribute?(reflection.foreign_key) && record._read_attribute(reflection.foreign_key) != key | |
end | |
# Saves the associated record if it's new or <tt>:autosave</tt> is enabled. | |
# | |
# In addition, it will destroy the association if it was marked for destruction. | |
def save_belongs_to_association(reflection) | |
association = association_instance_get(reflection.name) | |
return unless association && association.loaded? && !association.stale_target? | |
record = association.load_target | |
if record && !record.destroyed? | |
autosave = reflection.options[:autosave] | |
if autosave && record.marked_for_destruction? | |
self[reflection.foreign_key] = nil | |
record.destroy | |
elsif autosave != false | |
saved = record.save(validate: !autosave) if record.new_record? || (autosave && record.changed_for_autosave?) | |
if association.updated? | |
association_id = record.public_send(reflection.options[:primary_key] || :id) | |
self[reflection.foreign_key] = association_id | |
association.loaded! | |
end | |
saved if autosave | |
end | |
end | |
end | |
def custom_validation_context? | |
validation_context && [:create, :update].exclude?(validation_context) | |
end | |
def _ensure_no_duplicate_errors | |
errors.uniq! | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/author" | |
require "models/book" | |
require "models/bird" | |
require "models/post" | |
require "models/comment" | |
require "models/category" | |
require "models/company" | |
require "models/contract" | |
require "models/customer" | |
require "models/developer" | |
require "models/computer" | |
require "models/invoice" | |
require "models/line_item" | |
require "models/mouse" | |
require "models/order" | |
require "models/parrot" | |
require "models/pirate" | |
require "models/project" | |
require "models/ship" | |
require "models/ship_part" | |
require "models/squeak" | |
require "models/tag" | |
require "models/tagging" | |
require "models/treasure" | |
require "models/eye" | |
require "models/electron" | |
require "models/molecule" | |
require "models/member" | |
require "models/member_detail" | |
require "models/organization" | |
require "models/guitar" | |
require "models/tuning_peg" | |
require "models/reply" | |
require "models/attachment" | |
require "models/translation" | |
class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase | |
def test_autosave_works_even_when_other_callbacks_update_the_parent_model | |
reference = Class.new(ActiveRecord::Base) do | |
self.table_name = "references" | |
def self.name; "Reference"; end | |
end | |
person = Class.new(ActiveRecord::Base) do | |
self.table_name = "people" | |
def self.name; "Person"; end | |
# It is necessary that the after_create is before the has_many _and_ that it updates the model. | |
# This replicates a bug found in https://github.com/rails/rails/issues/38120 | |
after_create { update(first_name: "first name") } | |
has_many :references, autosave: true, anonymous_class: reference | |
end | |
reference_instance = reference.create! | |
person_instance = person.create!(first_name: "foo", references: [reference_instance]) | |
reference_instance.reload | |
assert_equal person_instance.id, reference_instance.person_id | |
assert_equal "first name", person_instance.first_name # Make sure the after_create is actually called | |
end | |
def test_autosave_does_not_pass_through_non_custom_validation_contexts | |
person = Class.new(ActiveRecord::Base) { | |
self.table_name = "people" | |
validate :should_be_cool, on: :create | |
def self.name; "Person"; end | |
private | |
def should_be_cool | |
unless first_name == "cool" | |
errors.add :first_name, "not cool" | |
end | |
end | |
} | |
reference = Class.new(ActiveRecord::Base) { | |
self.table_name = "references" | |
def self.name; "Reference"; end | |
belongs_to :person, autosave: true, anonymous_class: person | |
} | |
u = person.create!(first_name: "cool") | |
u.first_name = "nah" | |
assert_predicate u, :valid? | |
r = reference.new(person: u) | |
assert_predicate r, :valid? | |
end | |
def test_autosave_collection_association_callbacks_get_called_once | |
ship_with_saving_stack = Class.new(Ship) do | |
def save_collection_association(reflection) | |
@count ||= 0 | |
@count += 1 if reflection.name == :parts | |
super | |
end | |
end | |
ship = ship_with_saving_stack.new(name: "Nights Dirty Lightning") | |
ship.parts.build(name: "part") | |
ship.save! | |
assert_equal 1, ship.instance_variable_get(:@count) | |
end | |
def test_autosave_has_one_association_callbacks_get_called_once | |
# a bidirectional autosave is required to trigger multiple calls to | |
# save_has_one_association | |
assert Ship.reflect_on_association(:pirate).options[:autosave] | |
assert Pirate.reflect_on_association(:ship).options[:autosave] | |
pirate_with_saving_stack = Class.new(Pirate) do | |
def save_has_one_association(reflection) | |
@count ||= 0 | |
@count += 1 if reflection.name == :ship | |
super | |
end | |
end | |
pirate = pirate_with_saving_stack.new(catchphrase: "Aye") | |
pirate.build_ship(name: "Nights Dirty Lightning") | |
pirate.save! | |
assert_equal 1, pirate.instance_variable_get(:@count) | |
end | |
def test_autosave_belongs_to_association_callbacks_get_called_once | |
ship_with_saving_stack = Class.new(Ship) do | |
def save_belongs_to_association(reflection) | |
@count ||= 0 | |
@count += 1 if reflection.name == :pirate | |
super | |
end | |
end | |
ship = ship_with_saving_stack.new(name: "Nights Dirty Lightning") | |
ship.build_pirate(catchphrase: "Aye") | |
ship.save! | |
assert_equal 1, ship.instance_variable_get(:@count) | |
end | |
def test_should_not_add_the_same_callbacks_multiple_times_for_has_one | |
assert_no_difference_when_adding_callbacks_twice_for Pirate, :ship | |
end | |
def test_should_not_add_the_same_callbacks_multiple_times_for_belongs_to | |
assert_no_difference_when_adding_callbacks_twice_for Ship, :pirate | |
end | |
def test_should_not_add_the_same_callbacks_multiple_times_for_has_many | |
assert_no_difference_when_adding_callbacks_twice_for Pirate, :birds | |
end | |
def test_should_not_add_the_same_callbacks_multiple_times_for_has_and_belongs_to_many | |
assert_no_difference_when_adding_callbacks_twice_for Pirate, :parrots | |
end | |
def test_cyclic_autosaves_do_not_add_multiple_validations | |
ship = ShipWithoutNestedAttributes.new | |
ship.prisoners.build | |
assert_not_predicate ship, :valid? | |
assert_equal 1, ship.errors[:name].length | |
end | |
private | |
def assert_no_difference_when_adding_callbacks_twice_for(model, association_name) | |
reflection = model.reflect_on_association(association_name) | |
assert_no_difference "callbacks_for_model(#{model.name}).length" do | |
model.send(:add_autosave_association_callbacks, reflection) | |
end | |
end | |
def callbacks_for_model(model) | |
model.instance_variables.grep(/_callbacks$/).flat_map do |ivar| | |
model.instance_variable_get(ivar) | |
end | |
end | |
end | |
class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase | |
fixtures :companies, :accounts | |
def test_should_save_parent_but_not_invalid_child | |
firm = Firm.new(name: "GlobalMegaCorp") | |
assert_predicate firm, :valid? | |
firm.build_account_using_primary_key | |
assert_not_predicate firm.build_account_using_primary_key, :valid? | |
assert firm.save | |
assert_not_predicate firm.account_using_primary_key, :persisted? | |
end | |
def test_save_fails_for_invalid_has_one | |
firm = Firm.first | |
assert_predicate firm, :valid? | |
firm.build_account | |
assert_not_predicate firm.account, :valid? | |
assert_not_predicate firm, :valid? | |
assert_not firm.save | |
assert_equal ["is invalid"], firm.errors["account"] | |
end | |
def test_save_succeeds_for_invalid_has_one_with_validate_false | |
firm = Firm.first | |
assert_predicate firm, :valid? | |
firm.build_unvalidated_account | |
assert_not_predicate firm.unvalidated_account, :valid? | |
assert_predicate firm, :valid? | |
assert firm.save | |
end | |
def test_build_before_child_saved | |
firm = Firm.find(1) | |
account = firm.build_account("credit_limit" => 1000) | |
assert_equal account, firm.account | |
assert_not_predicate account, :persisted? | |
assert firm.save | |
assert_equal account, firm.account | |
assert_predicate account, :persisted? | |
end | |
def test_build_before_either_saved | |
firm = Firm.new("name" => "GlobalMegaCorp") | |
firm.account = account = Account.new("credit_limit" => 1000) | |
assert_equal account, firm.account | |
assert_not_predicate account, :persisted? | |
assert firm.save | |
assert_equal account, firm.account | |
assert_predicate account, :persisted? | |
end | |
def test_assignment_before_parent_saved | |
firm = Firm.new("name" => "GlobalMegaCorp") | |
firm.account = a = Account.find(1) | |
assert_not_predicate firm, :persisted? | |
assert_equal a, firm.account | |
assert firm.save | |
assert_equal a, firm.account | |
firm.association(:account).reload | |
assert_equal a, firm.account | |
end | |
def test_assignment_before_either_saved | |
firm = Firm.new("name" => "GlobalMegaCorp") | |
firm.account = a = Account.new("credit_limit" => 1000) | |
assert_not_predicate firm, :persisted? | |
assert_not_predicate a, :persisted? | |
assert_equal a, firm.account | |
assert firm.save | |
assert_predicate firm, :persisted? | |
assert_predicate a, :persisted? | |
assert_equal a, firm.account | |
firm.association(:account).reload | |
assert_equal a, firm.account | |
end | |
def test_not_resaved_when_unchanged | |
firm = Firm.all.merge!(includes: :account).first | |
firm.name += "-changed" | |
assert_queries(1) { firm.save! } | |
firm = Firm.first | |
firm.account = Account.first | |
assert_queries(Firm.partial_updates? ? 0 : 1) { firm.save! } | |
firm = Firm.first.dup | |
firm.account = Account.first | |
assert_queries(2) { firm.save! } | |
firm = Firm.first.dup | |
firm.account = Account.first.dup | |
assert_queries(2) { firm.save! } | |
end | |
def test_callbacks_firing_order_on_create | |
eye = Eye.create(iris_attributes: { color: "honey" }) | |
assert_equal [true, false], eye.after_create_callbacks_stack | |
end | |
def test_callbacks_firing_order_on_update | |
eye = Eye.create(iris_attributes: { color: "honey" }) | |
eye.update(iris_attributes: { color: "green" }) | |
assert_equal [true, false], eye.after_update_callbacks_stack | |
end | |
def test_callbacks_firing_order_on_save | |
eye = Eye.create(iris_attributes: { color: "honey" }) | |
assert_equal [false, false], eye.after_save_callbacks_stack | |
eye.update(iris_attributes: { color: "blue" }) | |
assert_equal [false, false, false, false], eye.after_save_callbacks_stack | |
end | |
end | |
class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase | |
fixtures :companies, :posts, :tags, :taggings | |
def test_should_save_parent_but_not_invalid_child | |
client = Client.new(name: "Joe (the Plumber)") | |
assert_predicate client, :valid? | |
client.build_firm | |
assert_not_predicate client.firm, :valid? | |
assert client.save | |
assert_not_predicate client.firm, :persisted? | |
end | |
def test_save_fails_for_invalid_belongs_to | |
# Oracle saves empty string as NULL therefore :message changed to one space | |
assert log = AuditLog.create(developer_id: 0, message: " ") | |
log.developer = Developer.new | |
assert_not_predicate log.developer, :valid? | |
assert_not_predicate log, :valid? | |
assert_not log.save | |
assert_equal ["is invalid"], log.errors["developer"] | |
end | |
def test_save_succeeds_for_invalid_belongs_to_with_validate_false | |
# Oracle saves empty string as NULL therefore :message changed to one space | |
assert log = AuditLog.create(developer_id: 0, message: " ") | |
log.unvalidated_developer = Developer.new | |
assert_not_predicate log.unvalidated_developer, :valid? | |
assert_predicate log, :valid? | |
assert log.save | |
end | |
def test_assignment_before_parent_saved | |
client = Client.first | |
apple = Firm.new("name" => "Apple") | |
client.firm = apple | |
assert_equal apple, client.firm | |
assert_not_predicate apple, :persisted? | |
assert client.save | |
assert apple.save | |
assert_predicate apple, :persisted? | |
assert_equal apple, client.firm | |
client.association(:firm).reload | |
assert_equal apple, client.firm | |
end | |
def test_assignment_before_either_saved | |
final_cut = Client.new("name" => "Final Cut") | |
apple = Firm.new("name" => "Apple") | |
final_cut.firm = apple | |
assert_not_predicate final_cut, :persisted? | |
assert_not_predicate apple, :persisted? | |
assert final_cut.save | |
assert_predicate final_cut, :persisted? | |
assert_predicate apple, :persisted? | |
assert_equal apple, final_cut.firm | |
final_cut.association(:firm).reload | |
assert_equal apple, final_cut.firm | |
end | |
def test_store_two_association_with_one_save | |
num_orders = Order.count | |
num_customers = Customer.count | |
order = Order.new | |
customer1 = order.billing = Customer.new | |
customer2 = order.shipping = Customer.new | |
assert order.save | |
assert_equal customer1, order.billing | |
assert_equal customer2, order.shipping | |
order.reload | |
assert_equal customer1, order.billing | |
assert_equal customer2, order.shipping | |
assert_equal num_orders + 1, Order.count | |
assert_equal num_customers + 2, Customer.count | |
end | |
def test_store_association_in_two_relations_with_one_save | |
num_orders = Order.count | |
num_customers = Customer.count | |
order = Order.new | |
customer = order.billing = order.shipping = Customer.new | |
assert order.save | |
assert_equal customer, order.billing | |
assert_equal customer, order.shipping | |
order.reload | |
assert_equal customer, order.billing | |
assert_equal customer, order.shipping | |
assert_equal num_orders + 1, Order.count | |
assert_equal num_customers + 1, Customer.count | |
end | |
def test_store_association_in_two_relations_with_one_save_in_existing_object | |
num_orders = Order.count | |
num_customers = Customer.count | |
order = Order.create | |
customer = order.billing = order.shipping = Customer.new | |
assert order.save | |
assert_equal customer, order.billing | |
assert_equal customer, order.shipping | |
order.reload | |
assert_equal customer, order.billing | |
assert_equal customer, order.shipping | |
assert_equal num_orders + 1, Order.count | |
assert_equal num_customers + 1, Customer.count | |
end | |
def test_store_association_in_two_relations_with_one_save_in_existing_object_with_values | |
num_orders = Order.count | |
num_customers = Customer.count | |
order = Order.create | |
customer = order.billing = order.shipping = Customer.new | |
assert order.save | |
assert_equal customer, order.billing | |
assert_equal customer, order.shipping | |
order.reload | |
customer = order.billing = order.shipping = Customer.new | |
assert order.save | |
order.reload | |
assert_equal customer, order.billing | |
assert_equal customer, order.shipping | |
assert_equal num_orders + 1, Order.count | |
assert_equal num_customers + 2, Customer.count | |
end | |
def test_store_association_with_a_polymorphic_relationship | |
num_tagging = Tagging.count | |
tags(:misc).create_tagging(taggable: posts(:thinking)) | |
assert_equal num_tagging + 1, Tagging.count | |
end | |
def test_build_and_then_save_parent_should_not_reload_target | |
client = Client.first | |
apple = client.build_firm(name: "Apple") | |
client.save! | |
assert_no_queries { assert_equal apple, client.firm } | |
end | |
def test_validation_does_not_validate_stale_association_target | |
valid_developer = Developer.create!(name: "Dude", salary: 50_000) | |
invalid_developer = Developer.new() | |
auditlog = AuditLog.new(message: "foo") | |
auditlog.developer = invalid_developer | |
auditlog.developer_id = valid_developer.id | |
assert_predicate auditlog, :valid? | |
end | |
def test_validation_does_not_validate_non_dirty_association_target | |
mouse = Mouse.create!(name: "Will") | |
Squeak.create!(mouse: mouse) | |
mouse.name = nil | |
mouse.save! validate: false | |
squeak = Squeak.last | |
assert_equal true, squeak.valid? | |
assert_equal true, squeak.mouse.present? | |
assert_equal true, squeak.valid? | |
end | |
end | |
class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttributes < ActiveRecord::TestCase | |
def test_invalid_adding_with_nested_attributes | |
molecule = Molecule.new | |
valid_electron = Electron.new(name: "electron") | |
invalid_electron = Electron.new | |
molecule.electrons = [valid_electron, invalid_electron] | |
molecule.save | |
assert_not_predicate invalid_electron, :valid? | |
assert_predicate valid_electron, :valid? | |
assert_not molecule.persisted?, "Molecule should not be persisted when its electrons are invalid" | |
end | |
def test_errors_should_be_indexed_when_passed_as_array | |
guitar = Guitar.new | |
tuning_peg_valid = TuningPeg.new | |
tuning_peg_valid.pitch = 440.0 | |
tuning_peg_invalid = TuningPeg.new | |
guitar.tuning_pegs = [tuning_peg_valid, tuning_peg_invalid] | |
assert_not_predicate tuning_peg_invalid, :valid? | |
assert_predicate tuning_peg_valid, :valid? | |
assert_not_predicate guitar, :valid? | |
assert_equal ["is not a number"], guitar.errors["tuning_pegs[1].pitch"] | |
assert_not_equal ["is not a number"], guitar.errors["tuning_pegs.pitch"] | |
end | |
def test_errors_should_be_indexed_when_global_flag_is_set | |
old_attribute_config = ActiveRecord.index_nested_attribute_errors | |
ActiveRecord.index_nested_attribute_errors = true | |
molecule = Molecule.new | |
valid_electron = Electron.new(name: "electron") | |
invalid_electron = Electron.new | |
molecule.electrons = [valid_electron, invalid_electron] | |
assert_not_predicate invalid_electron, :valid? | |
assert_predicate valid_electron, :valid? | |
assert_not_predicate molecule, :valid? | |
assert_equal ["can't be blank"], molecule.errors["electrons[1].name"] | |
assert_not_equal ["can't be blank"], molecule.errors["electrons.name"] | |
ensure | |
ActiveRecord.index_nested_attribute_errors = old_attribute_config | |
end | |
def test_errors_details_should_be_set | |
molecule = Molecule.new | |
valid_electron = Electron.new(name: "electron") | |
invalid_electron = Electron.new | |
molecule.electrons = [valid_electron, invalid_electron] | |
assert_not_predicate invalid_electron, :valid? | |
assert_predicate valid_electron, :valid? | |
assert_not_predicate molecule, :valid? | |
assert_equal [{ error: :blank }], molecule.errors.details[:"electrons.name"] | |
end | |
def test_errors_details_should_be_indexed_when_passed_as_array | |
guitar = Guitar.new | |
tuning_peg_valid = TuningPeg.new | |
tuning_peg_valid.pitch = 440.0 | |
tuning_peg_invalid = TuningPeg.new | |
guitar.tuning_pegs = [tuning_peg_valid, tuning_peg_invalid] | |
assert_not_predicate tuning_peg_invalid, :valid? | |
assert_predicate tuning_peg_valid, :valid? | |
assert_not_predicate guitar, :valid? | |
assert_equal [{ error: :not_a_number, value: nil }], guitar.errors.details[:"tuning_pegs[1].pitch"] | |
assert_equal [], guitar.errors.details[:"tuning_pegs.pitch"] | |
end | |
def test_errors_details_should_be_indexed_when_global_flag_is_set | |
old_attribute_config = ActiveRecord.index_nested_attribute_errors | |
ActiveRecord.index_nested_attribute_errors = true | |
molecule = Molecule.new | |
valid_electron = Electron.new(name: "electron") | |
invalid_electron = Electron.new | |
molecule.electrons = [valid_electron, invalid_electron] | |
assert_not_predicate invalid_electron, :valid? | |
assert_predicate valid_electron, :valid? | |
assert_not_predicate molecule, :valid? | |
assert_equal [{ error: :blank }], molecule.errors.details[:"electrons[1].name"] | |
assert_equal [], molecule.errors.details[:"electrons.name"] | |
ensure | |
ActiveRecord.index_nested_attribute_errors = old_attribute_config | |
end | |
def test_valid_adding_with_nested_attributes | |
molecule = Molecule.new | |
valid_electron = Electron.new(name: "electron") | |
molecule.electrons = [valid_electron] | |
molecule.save | |
assert_predicate valid_electron, :valid? | |
assert_predicate molecule, :persisted? | |
assert_equal 1, molecule.electrons.count | |
end | |
end | |
class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase | |
fixtures :companies, :developers | |
def test_invalid_adding | |
firm = Firm.find(1) | |
assert_not (firm.clients_of_firm << c = Client.new) | |
assert_not_predicate c, :persisted? | |
assert_not_predicate firm, :valid? | |
assert_not firm.save | |
assert_not_predicate c, :persisted? | |
end | |
def test_invalid_adding_before_save | |
new_firm = Firm.new("name" => "A New Firm, Inc") | |
new_firm.clients_of_firm.concat([c = Client.new, Client.new("name" => "Apple")]) | |
assert_not_predicate c, :persisted? | |
assert_not_predicate c, :valid? | |
assert_not_predicate new_firm, :valid? | |
assert_not new_firm.save | |
assert_not_predicate c, :persisted? | |
assert_not_predicate new_firm, :persisted? | |
end | |
def test_adding_unsavable_association | |
new_firm = Firm.new("name" => "A New Firm, Inc") | |
client = new_firm.clients.new("name" => "Apple") | |
client.throw_on_save = true | |
assert_predicate client, :valid? | |
assert_predicate new_firm, :valid? | |
assert_not new_firm.save | |
assert_not_predicate new_firm, :persisted? | |
assert_not_predicate client, :persisted? | |
end | |
def test_invalid_adding_with_validate_false | |
firm = Firm.first | |
client = Client.new | |
firm.unvalidated_clients_of_firm << client | |
assert_predicate firm, :valid? | |
assert_not_predicate client, :valid? | |
assert firm.save | |
assert_not_predicate client, :persisted? | |
end | |
def test_valid_adding_with_validate_false | |
no_of_clients = Client.count | |
firm = Firm.first | |
client = Client.new("name" => "Apple") | |
assert_predicate firm, :valid? | |
assert_predicate client, :valid? | |
assert_not_predicate client, :persisted? | |
firm.unvalidated_clients_of_firm << client | |
assert firm.save | |
assert_predicate client, :persisted? | |
assert_equal no_of_clients + 1, Client.count | |
end | |
def test_parent_should_save_children_record_with_foreign_key_validation_set_in_before_save_callback | |
company = NewlyContractedCompany.new(name: "test") | |
assert company.save | |
assert_not_empty company.reload.new_contracts | |
end | |
def test_parent_should_not_get_saved_with_duplicate_children_records | |
assert_no_difference "Reply.count" do | |
assert_no_difference "SillyUniqueReply.count" do | |
reply = Reply.new | |
reply.silly_unique_replies.build([ | |
{ content: "Best content" }, | |
{ content: "Best content" } | |
]) | |
assert_not reply.save | |
assert_equal ["is invalid"], reply.errors[:silly_unique_replies] | |
assert_empty reply.silly_unique_replies.first.errors | |
assert_equal( | |
["has already been taken"], | |
reply.silly_unique_replies.last.errors[:content] | |
) | |
end | |
end | |
end | |
def test_invalid_build | |
new_client = companies(:first_firm).clients_of_firm.build | |
assert_not_predicate new_client, :persisted? | |
assert_not_predicate new_client, :valid? | |
assert_equal new_client, companies(:first_firm).clients_of_firm.last | |
assert_not companies(:first_firm).save | |
assert_not_predicate new_client, :persisted? | |
assert_equal 2, companies(:first_firm).clients_of_firm.reload.size | |
end | |
def test_adding_before_save | |
no_of_firms = Firm.count | |
no_of_clients = Client.count | |
new_firm = Firm.new("name" => "A New Firm, Inc") | |
c = Client.new("name" => "Apple") | |
new_firm.clients_of_firm.push Client.new("name" => "Natural Company") | |
assert_equal 1, new_firm.clients_of_firm.size | |
new_firm.clients_of_firm << c | |
assert_equal 2, new_firm.clients_of_firm.size | |
assert_equal no_of_firms, Firm.count # Firm was not saved to database. | |
assert_equal no_of_clients, Client.count # Clients were not saved to database. | |
assert new_firm.save | |
assert_predicate new_firm, :persisted? | |
assert_predicate c, :persisted? | |
assert_equal new_firm, c.firm | |
assert_equal no_of_firms + 1, Firm.count # Firm was saved to database. | |
assert_equal no_of_clients + 2, Client.count # Clients were saved to database. | |
assert_equal 2, new_firm.clients_of_firm.size | |
assert_equal 2, new_firm.clients_of_firm.reload.size | |
end | |
def test_assign_ids | |
firm = Firm.new("name" => "Apple") | |
firm.client_ids = [companies(:first_client).id, companies(:second_client).id] | |
firm.save | |
firm.reload | |
assert_equal 2, firm.clients.length | |
assert_includes firm.clients, companies(:second_client) | |
end | |
def test_assign_ids_for_through_a_belongs_to | |
firm = Firm.new("name" => "Apple") | |
firm.developer_ids = [developers(:david).id, developers(:jamis).id] | |
firm.save | |
firm.reload | |
assert_equal 2, firm.developers.length | |
assert_includes firm.developers, developers(:david) | |
end | |
def test_build_before_save | |
company = companies(:first_firm) | |
new_client = assert_queries(0) { company.clients_of_firm.build("name" => "Another Client") } | |
assert_not_predicate company.clients_of_firm, :loaded? | |
company.name += "-changed" | |
assert_queries(2) { assert company.save } | |
assert_predicate new_client, :persisted? | |
assert_equal 3, company.clients_of_firm.reload.size | |
end | |
def test_build_many_before_save | |
company = companies(:first_firm) | |
assert_queries(0) { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) } | |
company.name += "-changed" | |
assert_queries(3) { assert company.save } | |
assert_equal 4, company.clients_of_firm.reload.size | |
end | |
def test_build_via_block_before_save | |
company = companies(:first_firm) | |
new_client = assert_queries(0) { company.clients_of_firm.build { |client| client.name = "Another Client" } } | |
assert_not_predicate company.clients_of_firm, :loaded? | |
company.name += "-changed" | |
assert_queries(2) { assert company.save } | |
assert_predicate new_client, :persisted? | |
assert_equal 3, company.clients_of_firm.reload.size | |
end | |
def test_build_many_via_block_before_save | |
company = companies(:first_firm) | |
assert_queries(0) do | |
company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) do |client| | |
client.name = "changed" | |
end | |
end | |
company.name += "-changed" | |
assert_queries(3) { assert company.save } | |
assert_equal 4, company.clients_of_firm.reload.size | |
end | |
def test_replace_on_new_object | |
firm = Firm.new("name" => "New Firm") | |
firm.clients = [companies(:second_client), Client.new("name" => "New Client")] | |
assert firm.save | |
firm.reload | |
assert_equal 2, firm.clients.length | |
assert_includes firm.clients, Client.find_by_name("New Client") | |
end | |
def test_replace_on_duplicated_object | |
firm = Firm.create!("name" => "New Firm").dup | |
firm.clients = [companies(:second_client), Client.new("name" => "New Client")] | |
assert firm.save | |
firm.reload | |
assert_equal 2, firm.clients.length | |
assert_includes firm.clients, Client.find_by_name("New Client") | |
end | |
end | |
class TestDefaultAutosaveAssociationOnNewRecord < ActiveRecord::TestCase | |
def test_autosave_new_record_on_belongs_to_can_be_disabled_per_relationship | |
new_account = Account.new("credit_limit" => 1000) | |
new_firm = Firm.new("name" => "some firm") | |
assert_not_predicate new_firm, :persisted? | |
new_account.firm = new_firm | |
new_account.save! | |
assert_predicate new_firm, :persisted? | |
new_account = Account.new("credit_limit" => 1000) | |
new_autosaved_firm = Firm.new("name" => "some firm") | |
assert_not_predicate new_autosaved_firm, :persisted? | |
new_account.unautosaved_firm = new_autosaved_firm | |
new_account.save! | |
assert_not_predicate new_autosaved_firm, :persisted? | |
end | |
def test_autosave_new_record_on_has_one_can_be_disabled_per_relationship | |
firm = Firm.new("name" => "some firm") | |
account = Account.new("credit_limit" => 1000) | |
assert_not_predicate account, :persisted? | |
firm.account = account | |
firm.save! | |
assert_predicate account, :persisted? | |
firm = Firm.new("name" => "some firm") | |
account = Account.new("credit_limit" => 1000) | |
firm.unautosaved_account = account | |
assert_not_predicate account, :persisted? | |
firm.unautosaved_account = account | |
firm.save! | |
assert_not_predicate account, :persisted? | |
end | |
def test_autosave_new_record_on_has_many_can_be_disabled_per_relationship | |
firm = Firm.new("name" => "some firm") | |
account = Account.new("credit_limit" => 1000) | |
assert_not_predicate account, :persisted? | |
firm.accounts << account | |
firm.save! | |
assert_predicate account, :persisted? | |
firm = Firm.new("name" => "some firm") | |
account = Account.new("credit_limit" => 1000) | |
assert_not_predicate account, :persisted? | |
firm.unautosaved_accounts << account | |
firm.save! | |
assert_not_predicate account, :persisted? | |
end | |
def test_autosave_new_record_with_after_create_callback | |
post = PostWithAfterCreateCallback.new(title: "Captain Murphy", body: "is back") | |
post.comments.build(body: "foo") | |
post.save! | |
assert_not_nil post.author_id | |
end | |
def test_autosave_new_record_with_after_create_callback_and_habtm_association | |
post = PostWithAfterCreateCallback.new(title: "Captain Murphy", body: "is back") | |
post.comments.build(body: "foo") | |
post.categories.build(name: "bar") | |
post.save! | |
assert_equal 1, post.categories.reload.length | |
end | |
end | |
class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false | |
setup do | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@ship = @pirate.create_ship(name: "Nights Dirty Lightning") | |
end | |
teardown do | |
# We are running without transactional tests and need to cleanup. | |
Bird.delete_all | |
Parrot.delete_all | |
@ship.delete | |
@pirate.delete | |
end | |
# reload | |
def test_a_marked_for_destruction_record_should_not_be_be_marked_after_reload | |
@pirate.mark_for_destruction | |
@pirate.ship.mark_for_destruction | |
assert_not_predicate @pirate.reload, :marked_for_destruction? | |
assert_not_predicate @pirate.ship.reload, :marked_for_destruction? | |
end | |
# has_one | |
def test_should_destroy_a_child_association_as_part_of_the_save_transaction_if_it_was_marked_for_destruction | |
assert_not_predicate @pirate.ship, :marked_for_destruction? | |
@pirate.ship.mark_for_destruction | |
id = @pirate.ship.id | |
assert_predicate @pirate.ship, :marked_for_destruction? | |
assert Ship.find_by_id(id) | |
@pirate.save | |
assert_nil @pirate.reload.ship | |
assert_nil Ship.find_by_id(id) | |
end | |
def test_should_skip_validation_on_a_child_association_if_marked_for_destruction | |
@pirate.ship.name = "" | |
assert_not_predicate @pirate, :valid? | |
@pirate.ship.mark_for_destruction | |
assert_not_called(@pirate.ship, :valid?) do | |
assert_difference("Ship.count", -1) { @pirate.save! } | |
end | |
end | |
def test_a_child_marked_for_destruction_should_not_be_destroyed_twice | |
@pirate.ship.mark_for_destruction | |
assert @pirate.save | |
class << @pirate.ship | |
def destroy; raise "Should not be called" end | |
end | |
assert @pirate.save | |
end | |
def test_should_rollback_destructions_if_an_exception_occurred_while_saving_a_child | |
# Stub the save method of the @pirate.ship instance to destroy and then raise an exception | |
class << @pirate.ship | |
def save(**) | |
super | |
destroy | |
raise "Oh noes!" | |
end | |
end | |
@ship.pirate.catchphrase = "Changed Catchphrase" | |
@ship.name_will_change! | |
assert_raise(RuntimeError) { assert_not @pirate.save } | |
assert_not_nil @pirate.reload.ship | |
end | |
def test_should_save_changed_has_one_changed_object_if_child_is_saved | |
@pirate.ship.name = "NewName" | |
assert @pirate.save | |
assert_equal "NewName", @pirate.ship.reload.name | |
end | |
def test_should_not_save_changed_has_one_unchanged_object_if_child_is_saved | |
assert_not_called(@pirate.ship, :save) do | |
assert @pirate.save | |
end | |
end | |
# belongs_to | |
def test_should_destroy_a_parent_association_as_part_of_the_save_transaction_if_it_was_marked_for_destruction | |
assert_not_predicate @ship.pirate, :marked_for_destruction? | |
@ship.pirate.mark_for_destruction | |
id = @ship.pirate.id | |
assert_predicate @ship.pirate, :marked_for_destruction? | |
assert Pirate.find_by_id(id) | |
@ship.save | |
assert_nil @ship.reload.pirate | |
assert_nil Pirate.find_by_id(id) | |
end | |
def test_should_skip_validation_on_a_parent_association_if_marked_for_destruction | |
@ship.pirate.catchphrase = "" | |
assert_not_predicate @ship, :valid? | |
@ship.pirate.mark_for_destruction | |
assert_not_called(@ship.pirate, :valid?) do | |
assert_difference("Pirate.count", -1) { @ship.save! } | |
end | |
end | |
def test_a_parent_marked_for_destruction_should_not_be_destroyed_twice | |
@ship.pirate.mark_for_destruction | |
assert @ship.save | |
class << @ship.pirate | |
def destroy; raise "Should not be called" end | |
end | |
assert @ship.save | |
end | |
def test_should_rollback_destructions_if_an_exception_occurred_while_saving_a_parent | |
# Stub the save method of the @ship.pirate instance to destroy and then raise an exception | |
class << @ship.pirate | |
def save(**) | |
super | |
destroy | |
raise "Oh noes!" | |
end | |
end | |
@ship.pirate.catchphrase = "Changed Catchphrase" | |
assert_raise(RuntimeError) { assert_not @ship.save } | |
assert_not_nil @ship.reload.pirate | |
end | |
def test_should_save_changed_child_objects_if_parent_is_saved | |
@pirate = @ship.create_pirate(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@parrot = @pirate.parrots.create!(name: "Posideons Killer") | |
@parrot.name = "NewName" | |
@ship.save | |
assert_equal "NewName", @parrot.reload.name | |
end | |
def test_should_destroy_has_many_as_part_of_the_save_transaction_if_they_were_marked_for_destruction | |
2.times { |i| @pirate.birds.create!(name: "birds_#{i}") } | |
assert_not @pirate.birds.any?(&:marked_for_destruction?) | |
@pirate.birds.each(&:mark_for_destruction) | |
klass = @pirate.birds.first.class | |
ids = @pirate.birds.map(&:id) | |
assert @pirate.birds.all?(&:marked_for_destruction?) | |
ids.each { |id| assert klass.find_by_id(id) } | |
@pirate.save | |
assert_empty @pirate.reload.birds | |
ids.each { |id| assert_nil klass.find_by_id(id) } | |
end | |
def test_should_not_resave_destroyed_association | |
@pirate.birds.create!(name: :parrot) | |
@pirate.birds.first.destroy | |
@pirate.save! | |
assert_empty @pirate.reload.birds | |
end | |
def test_should_skip_validation_on_has_many_if_marked_for_destruction | |
2.times { |i| @pirate.birds.create!(name: "birds_#{i}") } | |
@pirate.birds.each { |bird| bird.name = "" } | |
assert_not_predicate @pirate, :valid? | |
@pirate.birds.each(&:mark_for_destruction) | |
assert_not_called(@pirate.birds.first, :valid?) do | |
assert_not_called(@pirate.birds.last, :valid?) do | |
assert_difference("Bird.count", -2) { @pirate.save! } | |
end | |
end | |
end | |
def test_should_skip_validation_on_has_many_if_destroyed | |
@pirate.birds.create!(name: "birds_1") | |
@pirate.birds.each { |bird| bird.name = "" } | |
assert_not_predicate @pirate, :valid? | |
@pirate.birds.each(&:destroy) | |
assert_predicate @pirate, :valid? | |
end | |
def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_has_many | |
@pirate.birds.create!(name: "birds_1") | |
@pirate.birds.each(&:mark_for_destruction) | |
assert @pirate.save | |
@pirate.birds.each do |bird| | |
assert_not_called(bird, :destroy) do | |
assert @pirate.save | |
end | |
end | |
end | |
def test_should_rollback_destructions_if_an_exception_occurred_while_saving_has_many | |
2.times { |i| @pirate.birds.create!(name: "birds_#{i}") } | |
before = @pirate.birds.map { |c| c.mark_for_destruction ; c } | |
# Stub the destroy method of the second child to raise an exception | |
class << before.last | |
def destroy(*args) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_raise(RuntimeError) { assert_not @pirate.save } | |
assert_equal before, @pirate.reload.birds | |
end | |
def test_when_new_record_a_child_marked_for_destruction_should_not_affect_other_records_from_saving | |
@pirate = @ship.build_pirate(catchphrase: "Arr' now I shall keep me eye on you matey!") # new record | |
3.times { |i| @pirate.birds.build(name: "birds_#{i}") } | |
@pirate.birds[1].mark_for_destruction | |
@pirate.save! | |
assert_equal 2, @pirate.birds.reload.length | |
end | |
def test_should_save_new_record_that_has_same_value_as_existing_record_marked_for_destruction_on_field_that_has_unique_index | |
Bird.connection.add_index :birds, :name, unique: true | |
3.times { |i| @pirate.birds.create(name: "unique_birds_#{i}") } | |
@pirate.birds[0].mark_for_destruction | |
@pirate.birds.build(name: @pirate.birds[0].name) | |
@pirate.save! | |
assert_equal 3, @pirate.birds.reload.length | |
ensure | |
Bird.connection.remove_index :birds, column: :name | |
end | |
# Add and remove callbacks tests for association collections. | |
%w{ method proc }.each do |callback_type| | |
define_method("test_should_run_add_callback_#{callback_type}s_for_has_many") do | |
association_name_with_callbacks = "birds_with_#{callback_type}_callbacks" | |
pirate = Pirate.new(catchphrase: "Arr") | |
pirate.public_send(association_name_with_callbacks).build(name: "Crowe the One-Eyed") | |
expected = [ | |
"before_adding_#{callback_type}_bird_<new>", | |
"after_adding_#{callback_type}_bird_<new>" | |
] | |
assert_equal expected, pirate.ship_log | |
end | |
define_method("test_should_run_remove_callback_#{callback_type}s_for_has_many") do | |
association_name_with_callbacks = "birds_with_#{callback_type}_callbacks" | |
@pirate.public_send(association_name_with_callbacks).create!(name: "Crowe the One-Eyed") | |
@pirate.public_send(association_name_with_callbacks).each(&:mark_for_destruction) | |
child_id = @pirate.public_send(association_name_with_callbacks).first.id | |
@pirate.ship_log.clear | |
@pirate.save | |
expected = [ | |
"before_removing_#{callback_type}_bird_#{child_id}", | |
"after_removing_#{callback_type}_bird_#{child_id}" | |
] | |
assert_equal expected, @pirate.ship_log | |
end | |
end | |
def test_should_destroy_habtm_as_part_of_the_save_transaction_if_they_were_marked_for_destruction | |
2.times { |i| @pirate.parrots.create!(name: "parrots_#{i}") } | |
assert_not @pirate.parrots.any?(&:marked_for_destruction?) | |
@pirate.parrots.each(&:mark_for_destruction) | |
assert_no_difference "Parrot.count" do | |
@pirate.save | |
end | |
assert_empty @pirate.reload.parrots | |
join_records = Pirate.connection.select_all("SELECT * FROM parrots_pirates WHERE pirate_id = #{@pirate.id}") | |
assert_empty join_records | |
end | |
def test_should_skip_validation_on_habtm_if_marked_for_destruction | |
2.times { |i| @pirate.parrots.create!(name: "parrots_#{i}") } | |
@pirate.parrots.each { |parrot| parrot.name = "" } | |
assert_not_predicate @pirate, :valid? | |
@pirate.parrots.each { |parrot| parrot.mark_for_destruction } | |
assert_not_called(@pirate.parrots.first, :valid?) do | |
assert_not_called(@pirate.parrots.last, :valid?) do | |
@pirate.save! | |
end | |
end | |
assert_empty @pirate.reload.parrots | |
end | |
def test_should_skip_validation_on_habtm_if_destroyed | |
@pirate.parrots.create!(name: "parrots_1") | |
@pirate.parrots.each { |parrot| parrot.name = "" } | |
assert_not_predicate @pirate, :valid? | |
@pirate.parrots.each(&:destroy) | |
assert_predicate @pirate, :valid? | |
end | |
def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_habtm | |
@pirate.parrots.create!(name: "parrots_1") | |
@pirate.parrots.each(&:mark_for_destruction) | |
assert @pirate.save | |
Pirate.transaction do | |
assert_no_queries do | |
assert @pirate.save | |
end | |
end | |
end | |
def test_should_rollback_destructions_if_an_exception_occurred_while_saving_habtm | |
2.times { |i| @pirate.parrots.create!(name: "parrots_#{i}") } | |
before = @pirate.parrots.map { |c| c.mark_for_destruction ; c } | |
class << @pirate.association(:parrots) | |
def destroy(*args) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_raise(RuntimeError) { assert_not @pirate.save } | |
assert_equal before, @pirate.reload.parrots | |
end | |
# Add and remove callbacks tests for association collections. | |
%w{ method proc }.each do |callback_type| | |
define_method("test_should_run_add_callback_#{callback_type}s_for_habtm") do | |
association_name_with_callbacks = "parrots_with_#{callback_type}_callbacks" | |
pirate = Pirate.new(catchphrase: "Arr") | |
pirate.public_send(association_name_with_callbacks).build(name: "Crowe the One-Eyed") | |
expected = [ | |
"before_adding_#{callback_type}_parrot_<new>", | |
"after_adding_#{callback_type}_parrot_<new>" | |
] | |
assert_equal expected, pirate.ship_log | |
end | |
define_method("test_should_run_remove_callback_#{callback_type}s_for_habtm") do | |
association_name_with_callbacks = "parrots_with_#{callback_type}_callbacks" | |
@pirate.public_send(association_name_with_callbacks).create!(name: "Crowe the One-Eyed") | |
@pirate.public_send(association_name_with_callbacks).each(&:mark_for_destruction) | |
child_id = @pirate.public_send(association_name_with_callbacks).first.id | |
@pirate.ship_log.clear | |
@pirate.save | |
expected = [ | |
"before_removing_#{callback_type}_parrot_#{child_id}", | |
"after_removing_#{callback_type}_parrot_#{child_id}" | |
] | |
assert_equal expected, @pirate.ship_log | |
end | |
end | |
end | |
class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@ship = @pirate.create_ship(name: "Nights Dirty Lightning") | |
end | |
def test_should_still_work_without_an_associated_model | |
@ship.destroy | |
@pirate.reload.catchphrase = "Arr" | |
@pirate.save | |
assert_equal "Arr", @pirate.reload.catchphrase | |
end | |
def test_should_automatically_save_the_associated_model | |
@pirate.ship.name = "The Vile Serpent" | |
@pirate.save | |
assert_equal "The Vile Serpent", @pirate.reload.ship.name | |
end | |
def test_changed_for_autosave_should_handle_cycles | |
@ship.pirate = @pirate | |
assert_no_queries { @ship.save! } | |
@parrot = @pirate.parrots.create(name: "some_name") | |
@parrot.name = "changed_name" | |
assert_queries(1) { @ship.save! } | |
assert_no_queries { @ship.save! } | |
end | |
def test_should_automatically_save_bang_the_associated_model | |
@pirate.ship.name = "The Vile Serpent" | |
@pirate.save! | |
assert_equal "The Vile Serpent", @pirate.reload.ship.name | |
end | |
def test_should_automatically_validate_the_associated_model | |
@pirate.ship.name = "" | |
assert_predicate @pirate, :invalid? | |
assert_predicate @pirate.errors[:"ship.name"], :any? | |
end | |
def test_should_merge_errors_on_the_associated_models_onto_the_parent_even_if_it_is_not_valid | |
@pirate.ship.name = nil | |
@pirate.catchphrase = nil | |
assert_predicate @pirate, :invalid? | |
assert_predicate @pirate.errors[:"ship.name"], :any? | |
assert_predicate @pirate.errors[:catchphrase], :any? | |
end | |
def test_should_not_ignore_different_error_messages_on_the_same_attribute | |
old_validators = Ship._validators.deep_dup | |
old_callbacks = Ship._validate_callbacks.deep_dup | |
Ship.validates_format_of :name, with: /\w/ | |
@pirate.ship.name = "" | |
@pirate.catchphrase = nil | |
assert_predicate @pirate, :invalid? | |
assert_equal ["can't be blank", "is invalid"], @pirate.errors[:"ship.name"] | |
ensure | |
Ship._validators = old_validators if old_validators | |
Ship._validate_callbacks = old_callbacks if old_callbacks | |
end | |
def test_should_still_allow_to_bypass_validations_on_the_associated_model | |
@pirate.catchphrase = "" | |
@pirate.ship.name = "" | |
@pirate.save(validate: false) | |
# Oracle saves empty string as NULL | |
if current_adapter?(:OracleAdapter) | |
assert_equal [nil, nil], [@pirate.reload.catchphrase, @pirate.ship.name] | |
else | |
assert_equal ["", ""], [@pirate.reload.catchphrase, @pirate.ship.name] | |
end | |
end | |
def test_should_allow_to_bypass_validations_on_associated_models_at_any_depth | |
2.times { |i| @pirate.ship.parts.create!(name: "part #{i}") } | |
@pirate.catchphrase = "" | |
@pirate.ship.name = "" | |
@pirate.ship.parts.each { |part| part.name = "" } | |
@pirate.save(validate: false) | |
values = [@pirate.reload.catchphrase, @pirate.ship.name, *@pirate.ship.parts.map(&:name)] | |
# Oracle saves empty string as NULL | |
if current_adapter?(:OracleAdapter) | |
assert_equal [nil, nil, nil, nil], values | |
else | |
assert_equal ["", "", "", ""], values | |
end | |
end | |
def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that | |
@pirate.ship.name = "" | |
assert_raise(ActiveRecord::RecordInvalid) do | |
@pirate.save! | |
end | |
end | |
def test_should_not_save_and_return_false_if_a_callback_cancelled_saving | |
pirate = Pirate.new(catchphrase: "Arr") | |
ship = pirate.build_ship(name: "The Vile Serpent") | |
ship.cancel_save_from_callback = true | |
assert_no_difference "Pirate.count" do | |
assert_no_difference "Ship.count" do | |
assert_not pirate.save | |
end | |
end | |
end | |
def test_should_rollback_any_changes_if_an_exception_occurred_while_saving | |
before = [@pirate.catchphrase, @pirate.ship.name] | |
@pirate.catchphrase = "Arr" | |
@pirate.ship.name = "The Vile Serpent" | |
# Stub the save method of the @pirate.ship instance to raise an exception | |
class << @pirate.ship | |
def save(**) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_raise(RuntimeError) { assert_not @pirate.save } | |
assert_equal before, [@pirate.reload.catchphrase, @pirate.ship.name] | |
end | |
def test_should_not_load_the_associated_model | |
assert_queries(1) { @pirate.catchphrase = "Arr"; @pirate.save! } | |
end | |
def test_mark_for_destruction_is_ignored_without_autosave_true | |
ship = ShipWithoutNestedAttributes.new(name: "The Black Flag") | |
ship.parts.build.mark_for_destruction | |
assert_not_predicate ship, :valid? | |
end | |
end | |
class TestAutosaveAssociationOnAHasOneThroughAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def create_member_with_organization | |
organization = Organization.create | |
member = Member.create | |
MemberDetail.create(organization: organization, member: member) | |
member | |
end | |
def test_should_not_has_one_through_model | |
member = create_member_with_organization | |
class << member.organization | |
def save(**) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_nothing_raised { member.save } | |
end | |
def create_author_with_post_with_comment | |
Author.create! name: "David" # make comment_id not match author_id | |
author = Author.create! name: "Sergiy" | |
post = Post.create! author: author, title: "foo", body: "bar" | |
Comment.create! post: post, body: "cool comment" | |
author | |
end | |
def test_should_not_reversed_has_one_through_model | |
author = create_author_with_post_with_comment | |
class << author.comment_on_first_post | |
def save(**) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_nothing_raised { author.save } | |
end | |
end | |
class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@ship = Ship.create(name: "Nights Dirty Lightning") | |
@pirate = @ship.create_pirate(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
end | |
def test_should_still_work_without_an_associated_model | |
@pirate.destroy | |
@ship.reload.name = "The Vile Serpent" | |
@ship.save | |
assert_equal "The Vile Serpent", @ship.reload.name | |
end | |
def test_should_automatically_save_the_associated_model | |
@ship.pirate.catchphrase = "Arr" | |
@ship.save | |
assert_equal "Arr", @ship.reload.pirate.catchphrase | |
end | |
def test_should_automatically_save_bang_the_associated_model | |
@ship.pirate.catchphrase = "Arr" | |
@ship.save! | |
assert_equal "Arr", @ship.reload.pirate.catchphrase | |
end | |
def test_should_automatically_validate_the_associated_model | |
@ship.pirate.catchphrase = "" | |
assert_predicate @ship, :invalid? | |
assert_predicate @ship.errors[:"pirate.catchphrase"], :any? | |
end | |
def test_should_merge_errors_on_the_associated_model_onto_the_parent_even_if_it_is_not_valid | |
@ship.name = nil | |
@ship.pirate.catchphrase = nil | |
assert_predicate @ship, :invalid? | |
assert_predicate @ship.errors[:name], :any? | |
assert_predicate @ship.errors[:"pirate.catchphrase"], :any? | |
end | |
def test_should_still_allow_to_bypass_validations_on_the_associated_model | |
@ship.pirate.catchphrase = "" | |
@ship.name = "" | |
@ship.save(validate: false) | |
# Oracle saves empty string as NULL | |
if current_adapter?(:OracleAdapter) | |
assert_equal [nil, nil], [@ship.reload.name, @ship.pirate.catchphrase] | |
else | |
assert_equal ["", ""], [@ship.reload.name, @ship.pirate.catchphrase] | |
end | |
end | |
def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that | |
@ship.pirate.catchphrase = "" | |
assert_raise(ActiveRecord::RecordInvalid) do | |
@ship.save! | |
end | |
end | |
def test_should_not_save_and_return_false_if_a_callback_cancelled_saving | |
ship = Ship.new(name: "The Vile Serpent") | |
pirate = ship.build_pirate(catchphrase: "Arr") | |
pirate.cancel_save_from_callback = true | |
assert_no_difference "Ship.count" do | |
assert_no_difference "Pirate.count" do | |
assert_not ship.save | |
end | |
end | |
end | |
def test_should_rollback_any_changes_if_an_exception_occurred_while_saving | |
before = [@ship.pirate.catchphrase, @ship.name] | |
@ship.pirate.catchphrase = "Arr" | |
@ship.name = "The Vile Serpent" | |
# Stub the save method of the @ship.pirate instance to raise an exception | |
class << @ship.pirate | |
def save(**) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_raise(RuntimeError) { assert_not @ship.save } | |
assert_equal before, [@ship.pirate.reload.catchphrase, @ship.reload.name] | |
end | |
def test_should_not_load_the_associated_model | |
assert_queries(1) { @ship.name = "The Vile Serpent"; @ship.save! } | |
end | |
def test_should_save_with_non_nullable_foreign_keys | |
parent = Post.new title: "foo", body: "..." | |
child = parent.comments.build body: "..." | |
child.save! | |
assert_equal child.reload.post, parent.reload | |
end | |
end | |
module AutosaveAssociationOnACollectionAssociationTests | |
def test_should_automatically_save_the_associated_models | |
new_names = ["Grace OMalley", "Privateers Greed"] | |
@pirate.public_send(@association_name).each_with_index { |child, i| child.name = new_names[i] } | |
@pirate.save | |
assert_equal new_names.sort, @pirate.reload.public_send(@association_name).map(&:name).sort | |
end | |
def test_should_automatically_save_bang_the_associated_models | |
new_names = ["Grace OMalley", "Privateers Greed"] | |
@pirate.public_send(@association_name).each_with_index { |child, i| child.name = new_names[i] } | |
@pirate.save! | |
assert_equal new_names.sort, @pirate.reload.public_send(@association_name).map(&:name).sort | |
end | |
def test_should_update_children_when_autosave_is_true_and_parent_is_new_but_child_is_not | |
parrot = Parrot.create!(name: "Polly") | |
parrot.name = "Squawky" | |
pirate = Pirate.new(parrots: [parrot], catchphrase: "Arrrr") | |
pirate.save! | |
assert_equal "Squawky", parrot.reload.name | |
end | |
def test_should_not_update_children_when_parent_creation_with_no_reason | |
parrot = Parrot.create!(name: "Polly") | |
assert_equal 0, parrot.updated_count | |
Pirate.create!(parrot_ids: [parrot.id], catchphrase: "Arrrr") | |
assert_equal 0, parrot.reload.updated_count | |
end | |
def test_should_automatically_validate_the_associated_models | |
@pirate.public_send(@association_name).each { |child| child.name = "" } | |
assert_not_predicate @pirate, :valid? | |
assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] | |
assert_empty @pirate.errors[@association_name] | |
end | |
def test_should_not_use_default_invalid_error_on_associated_models | |
@pirate.public_send(@association_name).build(name: "") | |
assert_not_predicate @pirate, :valid? | |
assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] | |
assert_empty @pirate.errors[@association_name] | |
end | |
def test_should_default_invalid_error_from_i18n | |
I18n.backend.store_translations(:en, activerecord: { errors: { models: | |
{ @associated_model_name.to_s.to_sym => { blank: "cannot be blank" } } | |
} }) | |
@pirate.public_send(@association_name).build(name: "") | |
assert_not_predicate @pirate, :valid? | |
assert_equal ["cannot be blank"], @pirate.errors["#{@association_name}.name"] | |
assert_equal ["#{@association_name.to_s.humanize} name cannot be blank"], @pirate.errors.full_messages | |
assert_empty @pirate.errors[@association_name] | |
ensure | |
I18n.backend = I18n::Backend::Simple.new | |
end | |
def test_should_merge_errors_on_the_associated_models_onto_the_parent_even_if_it_is_not_valid | |
@pirate.public_send(@association_name).each { |child| child.name = "" } | |
@pirate.catchphrase = nil | |
assert_not_predicate @pirate, :valid? | |
assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] | |
assert_predicate @pirate.errors[:catchphrase], :any? | |
end | |
def test_should_allow_to_bypass_validations_on_the_associated_models_on_update | |
@pirate.catchphrase = "" | |
@pirate.public_send(@association_name).each { |child| child.name = "" } | |
assert @pirate.save(validate: false) | |
# Oracle saves empty string as NULL | |
if current_adapter?(:OracleAdapter) | |
assert_equal [nil, nil, nil], [ | |
@pirate.reload.catchphrase, | |
@pirate.public_send(@association_name).first.name, | |
@pirate.public_send(@association_name).last.name | |
] | |
else | |
assert_equal ["", "", ""], [ | |
@pirate.reload.catchphrase, | |
@pirate.public_send(@association_name).first.name, | |
@pirate.public_send(@association_name).last.name | |
] | |
end | |
end | |
def test_should_validation_the_associated_models_on_create | |
assert_no_difference("#{ @association_name == :birds ? 'Bird' : 'Parrot' }.count") do | |
2.times { @pirate.public_send(@association_name).build } | |
@pirate.save | |
end | |
end | |
def test_should_allow_to_bypass_validations_on_the_associated_models_on_create | |
assert_difference("#{ @association_name == :birds ? 'Bird' : 'Parrot' }.count", 2) do | |
2.times { @pirate.public_send(@association_name).build } | |
@pirate.save(validate: false) | |
end | |
end | |
def test_should_not_save_and_return_false_if_a_callback_cancelled_saving_in_either_create_or_update | |
@pirate.catchphrase = "Changed" | |
@child_1.name = "Changed" | |
@child_1.cancel_save_from_callback = true | |
assert_not @pirate.save | |
assert_equal "Don' botharrr talkin' like one, savvy?", @pirate.reload.catchphrase | |
assert_equal "Posideons Killer", @child_1.reload.name | |
new_pirate = Pirate.new(catchphrase: "Arr") | |
new_child = new_pirate.public_send(@association_name).build(name: "Grace OMalley") | |
new_child.cancel_save_from_callback = true | |
assert_no_difference "Pirate.count" do | |
assert_no_difference "#{new_child.class.name}.count" do | |
assert_not new_pirate.save | |
end | |
end | |
end | |
def test_should_rollback_any_changes_if_an_exception_occurred_while_saving | |
before = [@pirate.catchphrase, *@pirate.public_send(@association_name).map(&:name)] | |
new_names = ["Grace OMalley", "Privateers Greed"] | |
@pirate.catchphrase = "Arr" | |
@pirate.public_send(@association_name).each_with_index { |child, i| child.name = new_names[i] } | |
# Stub the save method of the first child instance to raise an exception | |
class << @pirate.public_send(@association_name).first | |
def save(**) | |
super | |
raise "Oh noes!" | |
end | |
end | |
assert_raise(RuntimeError) { assert_not @pirate.save } | |
assert_equal before, [@pirate.reload.catchphrase, *@pirate.send(@association_name).map(&:name)] | |
end | |
def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that | |
@pirate.public_send(@association_name).each { |child| child.name = "" } | |
assert_raise(ActiveRecord::RecordInvalid) do | |
@pirate.save! | |
end | |
end | |
def test_should_not_load_the_associated_models_if_they_were_not_loaded_yet | |
assert_queries(1) { @pirate.catchphrase = "Arr"; @pirate.save! } | |
@pirate.public_send(@association_name).load_target | |
assert_queries(3) do | |
@pirate.catchphrase = "Yarr" | |
new_names = ["Grace OMalley", "Privateers Greed"] | |
@pirate.public_send(@association_name).each_with_index { |child, i| child.name = new_names[i] } | |
@pirate.save! | |
end | |
end | |
end | |
class TestAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@association_name = :birds | |
@associated_model_name = :bird | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@child_1 = @pirate.birds.create(name: "Posideons Killer") | |
@child_2 = @pirate.birds.create(name: "Killer bandita Dionne") | |
end | |
include AutosaveAssociationOnACollectionAssociationTests | |
end | |
class TestAutosaveAssociationOnAHasAndBelongsToManyAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@association_name = :autosaved_parrots | |
@associated_model_name = :parrot | |
@habtm = true | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@child_1 = @pirate.parrots.create(name: "Posideons Killer") | |
@child_2 = @pirate.parrots.create(name: "Killer bandita Dionne") | |
end | |
include AutosaveAssociationOnACollectionAssociationTests | |
end | |
class TestAutosaveAssociationOnAHasAndBelongsToManyAssociationWithAcceptsNestedAttributes < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@association_name = :parrots | |
@associated_model_name = :parrot | |
@habtm = true | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@child_1 = @pirate.parrots.create(name: "Posideons Killer") | |
@child_2 = @pirate.parrots.create(name: "Killer bandita Dionne") | |
end | |
include AutosaveAssociationOnACollectionAssociationTests | |
end | |
class TestAutosaveAssociationValidationsOnAHasManyAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@pirate.birds.create(name: "cookoo") | |
@author = Author.new(name: "DHH") | |
@author.published_books.build(name: "Rework", isbn: "1234") | |
@author.published_books.build(name: "Remote", isbn: "1234") | |
end | |
test "should automatically validate associations" do | |
assert_predicate @pirate, :valid? | |
@pirate.birds.each { |bird| bird.name = "" } | |
assert_not_predicate @pirate, :valid? | |
end | |
test "rollbacks whole transaction and raises ActiveRecord::RecordInvalid when associations fail to #save! due to uniqueness validation failure" do | |
author_count_before_save = Author.count | |
book_count_before_save = Book.count | |
assert_no_difference "Author.count" do | |
assert_no_difference "Book.count" do | |
exception = assert_raises(ActiveRecord::RecordInvalid) do | |
@author.save! | |
end | |
assert_equal("Validation failed: Published books is invalid", exception.message) | |
end | |
end | |
assert_equal(author_count_before_save, Author.count) | |
assert_equal(book_count_before_save, Book.count) | |
end | |
test "rollbacks whole transaction when associations fail to #save due to uniqueness validation failure" do | |
author_count_before_save = Author.count | |
book_count_before_save = Book.count | |
assert_no_difference "Author.count" do | |
assert_no_difference "Book.count" do | |
assert_nothing_raised do | |
result = @author.save | |
assert_not(result) | |
end | |
end | |
end | |
assert_equal(author_count_before_save, Author.count) | |
assert_equal(book_count_before_save, Book.count) | |
end | |
def test_validations_still_fire_on_unchanged_association_with_custom_validation_context | |
pirate = FamousPirate.create!(catchphrase: "Avast Ye!") | |
pirate.famous_ships.create! | |
assert pirate.valid? | |
assert_not pirate.valid?(:conference) | |
end | |
end | |
class TestAutosaveAssociationValidationsOnAHasOneAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
@pirate.create_ship(name: "titanic") | |
super | |
end | |
test "should automatically validate associations with :validate => true" do | |
assert_predicate @pirate, :valid? | |
@pirate.ship.name = "" | |
assert_not_predicate @pirate, :valid? | |
end | |
test "should not automatically add validate associations without :validate => true" do | |
assert_predicate @pirate, :valid? | |
@pirate.non_validated_ship.name = "" | |
assert_predicate @pirate, :valid? | |
end | |
end | |
class TestAutosaveAssociationValidationsOnABelongsToAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
end | |
test "should automatically validate associations with :validate => true" do | |
assert_predicate @pirate, :valid? | |
@pirate.parrot = Parrot.new(name: "") | |
assert_not_predicate @pirate, :valid? | |
end | |
test "should not automatically validate associations without :validate => true" do | |
assert_predicate @pirate, :valid? | |
@pirate.non_validated_parrot = Parrot.new(name: "") | |
assert_predicate @pirate, :valid? | |
end | |
def test_validations_still_fire_on_unchanged_association_with_custom_validation_context | |
firm_with_low_credit = Firm.create!(name: "Something", account: Account.new(credit_limit: 50)) | |
assert firm_with_low_credit.valid? | |
assert_not firm_with_low_credit.valid?(:bank_loan) | |
end | |
end | |
class TestAutosaveAssociationValidationsOnAHABTMAssociation < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") | |
end | |
test "should automatically validate associations with :validate => true" do | |
assert_predicate @pirate, :valid? | |
@pirate.parrots = [ Parrot.new(name: "popuga") ] | |
@pirate.parrots.each { |parrot| parrot.name = "" } | |
assert_not_predicate @pirate, :valid? | |
end | |
test "should not automatically validate associations without :validate => true" do | |
assert_predicate @pirate, :valid? | |
@pirate.non_validated_parrots = [ Parrot.new(name: "popuga") ] | |
@pirate.non_validated_parrots.each { |parrot| parrot.name = "" } | |
assert_predicate @pirate, :valid? | |
end | |
end | |
class TestAutosaveAssociationValidationMethodsGeneration < ActiveRecord::TestCase | |
self.use_transactional_tests = false unless supports_savepoints? | |
def setup | |
super | |
@pirate = Pirate.new | |
end | |
test "should generate validation methods for has_many associations" do | |
assert_respond_to @pirate, :validate_associated_records_for_birds | |
end | |
test "should generate validation methods for has_one associations with :validate => true" do | |
assert_respond_to @pirate, :validate_associated_records_for_ship | |
end | |
test "should not generate validation methods for has_one associations without :validate => true" do | |
assert_not_respond_to @pirate, :validate_associated_records_for_non_validated_ship | |
end | |
test "should generate validation methods for belongs_to associations with :validate => true" do | |
assert_respond_to @pirate, :validate_associated_records_for_parrot | |
end | |
test "should not generate validation methods for belongs_to associations without :validate => true" do | |
assert_not_respond_to @pirate, :validate_associated_records_for_non_validated_parrot | |
end | |
test "should generate validation methods for HABTM associations with :validate => true" do | |
assert_respond_to @pirate, :validate_associated_records_for_parrots | |
end | |
end | |
class TestAutosaveAssociationWithTouch < ActiveRecord::TestCase | |
def test_autosave_with_touch_should_not_raise_system_stack_error | |
invoice = Invoice.create | |
assert_nothing_raised { invoice.line_items.create(amount: 10) } | |
end | |
end | |
class TestAutosaveAssociationOnAHasManyAssociationWithInverse < ActiveRecord::TestCase | |
class Post < ActiveRecord::Base | |
has_many :comments, inverse_of: :post | |
end | |
class Comment < ActiveRecord::Base | |
belongs_to :post, inverse_of: :comments | |
attr_accessor :post_comments_count | |
after_save do | |
self.post_comments_count = post.comments.count | |
end | |
end | |
def setup | |
Comment.delete_all | |
end | |
def test_after_save_callback_with_autosave | |
post = Post.new(title: "Test", body: "...") | |
comment = post.comments.build(body: "...") | |
post.save! | |
assert_equal 1, post.comments.count | |
assert_equal 1, comment.post_comments_count | |
end | |
end | |
class TestAutosaveAssociationOnAHasManyAssociationDefinedInSubclassWithAcceptsNestedAttributes < ActiveRecord::TestCase | |
def test_should_update_children_when_association_redefined_in_subclass | |
agency = Agency.create!(name: "Agency") | |
valid_project = Project.create!(firm: agency, name: "Initial") | |
agency.update!( | |
"projects_attributes" => { | |
"0" => { | |
"name" => "Updated", | |
"id" => valid_project.id | |
} | |
} | |
) | |
valid_project.reload | |
assert_equal "Updated", valid_project.name | |
end | |
end | |
class TestAutosaveAssociationOnABelongsToAssociationDefinedAsRecord < ActiveRecord::TestCase | |
def test_should_not_raise_error | |
translation = Translation.create(locale: "fr", key: "bread", value: "Baguette 🥖") | |
author = Author.create(name: "Dorian Marié") | |
translation.build_attachment(record: author) | |
assert_nothing_raised do | |
translation.save! | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "service/shared_service_tests" | |
require "uri" | |
if SERVICE_CONFIGURATIONS[:azure_public] | |
class ActiveStorage::Service::AzureStoragePublicServiceTest < ActiveSupport::TestCase | |
SERVICE = ActiveStorage::Service.configure(:azure_public, SERVICE_CONFIGURATIONS) | |
include ActiveStorage::Service::SharedServiceTests | |
test "public URL generation" do | |
url = @service.url(@key, filename: ActiveStorage::Filename.new("avatar.png")) | |
assert_match(/.*\.blob\.core\.windows\.net\/.*\/#{@key}/, url) | |
response = Net::HTTP.get_response(URI(url)) | |
assert_equal "200", response.code | |
end | |
test "direct upload" do | |
key = SecureRandom.base58(24) | |
data = "Something else entirely!" | |
checksum = OpenSSL::Digest::MD5.base64digest(data) | |
content_type = "text/xml" | |
url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: content_type, content_length: data.size, checksum: checksum) | |
uri = URI.parse url | |
request = Net::HTTP::Put.new uri.request_uri | |
request.body = data | |
@service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v| | |
request.add_field k, v | |
end | |
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| | |
http.request request | |
end | |
response = Net::HTTP.get_response(URI(@service.url(key))) | |
assert_equal "200", response.code | |
assert_equal data, response.body | |
ensure | |
@service.delete key | |
end | |
end | |
else | |
puts "Skipping Azure Storage Public Service tests because no Azure configuration was supplied" | |
end |
# frozen_string_literal: true | |
gem "azure-storage-blob", ">= 2.0" | |
require "active_support/core_ext/numeric/bytes" | |
require "azure/storage/blob" | |
require "azure/storage/common/core/auth/shared_access_signature" | |
module ActiveStorage | |
# Wraps the Microsoft Azure Storage Blob Service as an Active Storage service. | |
# See ActiveStorage::Service for the generic API documentation that applies to all services. | |
class Service::AzureStorageService < Service | |
attr_reader :client, :container, :signer | |
def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options) | |
@client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options) | |
@signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key) | |
@container = container | |
@public = public | |
end | |
def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **) | |
instrument :upload, key: key, checksum: checksum do | |
handle_errors do | |
content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename | |
client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata) | |
end | |
end | |
end | |
def download(key, &block) | |
if block_given? | |
instrument :streaming_download, key: key do | |
stream(key, &block) | |
end | |
else | |
instrument :download, key: key do | |
handle_errors do | |
_, io = client.get_blob(container, key) | |
io.force_encoding(Encoding::BINARY) | |
end | |
end | |
end | |
end | |
def download_chunk(key, range) | |
instrument :download_chunk, key: key, range: range do | |
handle_errors do | |
_, io = client.get_blob(container, key, start_range: range.begin, end_range: range.exclude_end? ? range.end - 1 : range.end) | |
io.force_encoding(Encoding::BINARY) | |
end | |
end | |
end | |
def delete(key) | |
instrument :delete, key: key do | |
client.delete_blob(container, key) | |
rescue Azure::Core::Http::HTTPError => e | |
raise unless e.type == "BlobNotFound" | |
# Ignore files already deleted | |
end | |
end | |
def delete_prefixed(prefix) | |
instrument :delete_prefixed, prefix: prefix do | |
marker = nil | |
loop do | |
results = client.list_blobs(container, prefix: prefix, marker: marker) | |
results.each do |blob| | |
client.delete_blob(container, blob.name) | |
end | |
break unless marker = results.continuation_token.presence | |
end | |
end | |
end | |
def exist?(key) | |
instrument :exist, key: key do |payload| | |
answer = blob_for(key).present? | |
payload[:exist] = answer | |
answer | |
end | |
end | |
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {}) | |
instrument :url, key: key do |payload| | |
generated_url = signer.signed_uri( | |
uri_for(key), false, | |
service: "b", | |
permissions: "rw", | |
expiry: format_expiry(expires_in) | |
).to_s | |
payload[:url] = generated_url | |
generated_url | |
end | |
end | |
def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **) | |
content_disposition = content_disposition_with(type: disposition, filename: filename) if filename | |
{ "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) } | |
end | |
def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) | |
content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename | |
client.create_append_blob( | |
container, | |
destination_key, | |
content_type: content_type, | |
content_disposition: content_disposition, | |
metadata: custom_metadata, | |
).tap do |blob| | |
source_keys.each do |source_key| | |
stream(source_key) do |chunk| | |
client.append_blob_block(container, blob.name, chunk) | |
end | |
end | |
end | |
end | |
private | |
def private_url(key, expires_in:, filename:, disposition:, content_type:, **) | |
signer.signed_uri( | |
uri_for(key), false, | |
service: "b", | |
permissions: "r", | |
expiry: format_expiry(expires_in), | |
content_disposition: content_disposition_with(type: disposition, filename: filename), | |
content_type: content_type | |
).to_s | |
end | |
def public_url(key, **) | |
uri_for(key).to_s | |
end | |
def uri_for(key) | |
client.generate_uri("#{container}/#{key}") | |
end | |
def blob_for(key) | |
client.get_blob_properties(container, key) | |
rescue Azure::Core::Http::HTTPError | |
false | |
end | |
def format_expiry(expires_in) | |
expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil | |
end | |
# Reads the object for the given key in chunks, yielding each to the block. | |
def stream(key) | |
blob = blob_for(key) | |
chunk_size = 5.megabytes | |
offset = 0 | |
raise ActiveStorage::FileNotFoundError unless blob.present? | |
while offset < blob.properties[:content_length] | |
_, chunk = client.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1) | |
yield chunk.force_encoding(Encoding::BINARY) | |
offset += chunk_size | |
end | |
end | |
def handle_errors | |
yield | |
rescue Azure::Core::Http::HTTPError => e | |
case e.type | |
when "BlobNotFound" | |
raise ActiveStorage::FileNotFoundError | |
when "Md5Mismatch" | |
raise ActiveStorage::IntegrityError | |
else | |
raise | |
end | |
end | |
def custom_metadata_headers(metadata) | |
metadata.transform_keys { |key| "x-ms-meta-#{key}" } | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "service/shared_service_tests" | |
require "uri" | |
if SERVICE_CONFIGURATIONS[:azure] | |
class ActiveStorage::Service::AzureStorageServiceTest < ActiveSupport::TestCase | |
SERVICE = ActiveStorage::Service.configure(:azure, SERVICE_CONFIGURATIONS) | |
include ActiveStorage::Service::SharedServiceTests | |
test "direct upload with content type" do | |
key = SecureRandom.base58(24) | |
data = "Something else entirely!" | |
checksum = OpenSSL::Digest::MD5.base64digest(data) | |
content_type = "text/xml" | |
url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: content_type, content_length: data.size, checksum: checksum) | |
uri = URI.parse url | |
request = Net::HTTP::Put.new uri.request_uri | |
request.body = data | |
@service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v| | |
request.add_field k, v | |
end | |
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| | |
http.request request | |
end | |
assert_equal(content_type, @service.client.get_blob_properties(@service.container, key).properties[:content_type]) | |
ensure | |
@service.delete key | |
end | |
test "direct upload with content disposition" do | |
key = SecureRandom.base58(24) | |
data = "Something else entirely!" | |
checksum = OpenSSL::Digest::MD5.base64digest(data) | |
url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) | |
uri = URI.parse url | |
request = Net::HTTP::Put.new uri.request_uri | |
request.body = data | |
@service.headers_for_direct_upload(key, checksum: checksum, content_type: "text/plain", filename: ActiveStorage::Filename.new("test.txt"), disposition: :attachment).each do |k, v| | |
request.add_field k, v | |
end | |
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| | |
http.request request | |
end | |
assert_equal("attachment; filename=\"test.txt\"; filename*=UTF-8''test.txt", @service.client.get_blob_properties(@service.container, key).properties[:content_disposition]) | |
ensure | |
@service.delete key | |
end | |
test "upload with content_type" do | |
key = SecureRandom.base58(24) | |
data = "Foobar" | |
@service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") | |
url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: nil, filename: ActiveStorage::Filename.new("test.html")) | |
response = Net::HTTP.get_response(URI(url)) | |
assert_equal "text/plain", response.content_type | |
assert_match(/attachment;.*test\.html/, response["Content-Disposition"]) | |
ensure | |
@service.delete key | |
end | |
test "upload with content disposition" do | |
key = SecureRandom.base58(24) | |
data = "Foobar" | |
@service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), disposition: :inline) | |
assert_equal("inline; filename=\"test.txt\"; filename*=UTF-8''test.txt", @service.client.get_blob_properties(@service.container, key).properties[:content_disposition]) | |
url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: nil, filename: ActiveStorage::Filename.new("test.html")) | |
response = Net::HTTP.get_response(URI(url)) | |
assert_match(/attachment;.*test\.html/, response["Content-Disposition"]) | |
ensure | |
@service.delete key | |
end | |
test "upload with custom_metadata" do | |
key = SecureRandom.base58(24) | |
data = "Foobar" | |
@service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), custom_metadata: { "foo" => "baz" }) | |
url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) | |
response = Net::HTTP.get_response(URI(url)) | |
assert_equal("baz", response["x-ms-meta-foo"]) | |
ensure | |
@service.delete key | |
end | |
test "signed URL generation" do | |
url = @service.url(@key, expires_in: 5.minutes, | |
disposition: :inline, filename: ActiveStorage::Filename.new("avatar.png"), content_type: "image/png") | |
assert_match(/(\S+)&rscd=inline%3B\+filename%3D%22avatar\.png%22%3B\+filename\*%3DUTF-8%27%27avatar\.png&rsct=image%2Fpng/, url) | |
assert_match SERVICE_CONFIGURATIONS[:azure][:container], url | |
end | |
test "uploading a tempfile" do | |
key = SecureRandom.base58(24) | |
data = "Something else entirely!" | |
Tempfile.open do |file| | |
file.write(data) | |
file.rewind | |
@service.upload(key, file) | |
end | |
assert_equal data, @service.download(key) | |
ensure | |
@service.delete(key) | |
end | |
end | |
else | |
puts "Skipping Azure Storage Service tests because no Azure configuration was supplied" | |
end |
# frozen_string_literal: true | |
module BackburnerJobsManager | |
def setup | |
ActiveJob::Base.queue_adapter = :backburner | |
Backburner.configure do |config| | |
config.beanstalk_url = ENV["BEANSTALK_URL"] if ENV["BEANSTALK_URL"] | |
config.logger = Rails.logger | |
end | |
unless can_run? | |
puts "Cannot run integration tests for backburner. To be able to run integration tests for backburner you need to install and start beanstalkd.\n" | |
status = ENV["CI"] ? false : true | |
exit status | |
end | |
end | |
def clear_jobs | |
tube.clear | |
end | |
def start_workers | |
@thread = Thread.new { Backburner.work "integration-tests" } # backburner dasherizes the queue name | |
end | |
def stop_workers | |
@thread.kill | |
end | |
def tube | |
@tube ||= Beaneater::Tube.new(@worker.connection, "backburner.worker.queue.integration-tests") # backburner dasherizes the queue name | |
end | |
def can_run? | |
begin | |
@worker = Backburner::Worker.new | |
rescue | |
return false | |
end | |
true | |
end | |
end |
# frozen_string_literal: true | |
require "backburner" | |
module ActiveJob | |
module QueueAdapters | |
# == Backburner adapter for Active Job | |
# | |
# Backburner is a beanstalkd-powered job queue that can handle a very | |
# high volume of jobs. You create background jobs and place them on | |
# multiple work queues to be processed later. Read more about | |
# Backburner {here}[https://github.com/nesquena/backburner]. | |
# | |
# To use Backburner set the queue_adapter config to +:backburner+. | |
# | |
# Rails.application.config.active_job.queue_adapter = :backburner | |
class BackburnerAdapter | |
def enqueue(job) # :nodoc: | |
Backburner::Worker.enqueue(JobWrapper, [job.serialize], queue: job.queue_name, pri: job.priority) | |
end | |
def enqueue_at(job, timestamp) # :nodoc: | |
delay = timestamp - Time.current.to_f | |
Backburner::Worker.enqueue(JobWrapper, [job.serialize], queue: job.queue_name, pri: job.priority, delay: delay) | |
end | |
class JobWrapper # :nodoc: | |
class << self | |
def perform(job_data) | |
Base.execute job_data | |
end | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveSupport | |
# Backtraces often include many lines that are not relevant for the context | |
# under review. This makes it hard to find the signal amongst the backtrace | |
# noise, and adds debugging time. With a BacktraceCleaner, filters and | |
# silencers are used to remove the noisy lines, so that only the most relevant | |
# lines remain. | |
# | |
# Filters are used to modify lines of data, while silencers are used to remove | |
# lines entirely. The typical filter use case is to remove lengthy path | |
# information from the start of each line, and view file paths relevant to the | |
# app directory instead of the file system root. The typical silencer use case | |
# is to exclude the output of a noisy library from the backtrace, so that you | |
# can focus on the rest. | |
# | |
# bc = ActiveSupport::BacktraceCleaner.new | |
# bc.add_filter { |line| line.gsub(Rails.root.to_s, '') } # strip the Rails.root prefix | |
# bc.add_silencer { |line| /puma|rubygems/.match?(line) } # skip any lines from puma or rubygems | |
# bc.clean(exception.backtrace) # perform the cleanup | |
# | |
# To reconfigure an existing BacktraceCleaner (like the default one in Rails) | |
# and show as much data as possible, you can always call | |
# BacktraceCleaner#remove_silencers!, which will restore the | |
# backtrace to a pristine state. If you need to reconfigure an existing | |
# BacktraceCleaner so that it does not filter or modify the paths of any lines | |
# of the backtrace, you can call BacktraceCleaner#remove_filters! | |
# These two methods will give you a completely untouched backtrace. | |
# | |
# Inspired by the Quiet Backtrace gem by thoughtbot. | |
class BacktraceCleaner | |
def initialize | |
@filters, @silencers = [], [] | |
add_gem_filter | |
add_gem_silencer | |
add_stdlib_silencer | |
end | |
# Returns the backtrace after all filters and silencers have been run | |
# against it. Filters run first, then silencers. | |
def clean(backtrace, kind = :silent) | |
filtered = filter_backtrace(backtrace) | |
case kind | |
when :silent | |
silence(filtered) | |
when :noise | |
noise(filtered) | |
else | |
filtered | |
end | |
end | |
alias :filter :clean | |
# Adds a filter from the block provided. Each line in the backtrace will be | |
# mapped against this filter. | |
# | |
# # Will turn "/my/rails/root/app/models/person.rb" into "/app/models/person.rb" | |
# backtrace_cleaner.add_filter { |line| line.gsub(Rails.root.to_s, '') } | |
def add_filter(&block) | |
@filters << block | |
end | |
# Adds a silencer from the block provided. If the silencer returns +true+ | |
# for a given line, it will be excluded from the clean backtrace. | |
# | |
# # Will reject all lines that include the word "puma", like "/gems/puma/server.rb" or "/app/my_puma_server/rb" | |
# backtrace_cleaner.add_silencer { |line| /puma/.match?(line) } | |
def add_silencer(&block) | |
@silencers << block | |
end | |
# Removes all silencers, but leaves in the filters. Useful if your | |
# context of debugging suddenly expands as you suspect a bug in one of | |
# the libraries you use. | |
def remove_silencers! | |
@silencers = [] | |
end | |
# Removes all filters, but leaves in the silencers. Useful if you suddenly | |
# need to see entire filepaths in the backtrace that you had already | |
# filtered out. | |
def remove_filters! | |
@filters = [] | |
end | |
private | |
FORMATTED_GEMS_PATTERN = /\A[^\/]+ \([\w.]+\) / | |
def add_gem_filter | |
gems_paths = (Gem.path | [Gem.default_dir]).map { |p| Regexp.escape(p) } | |
return if gems_paths.empty? | |
gems_regexp = %r{\A(#{gems_paths.join('|')})/(bundler/)?gems/([^/]+)-([\w.]+)/(.*)} | |
gems_result = '\3 (\4) \5' | |
add_filter { |line| line.sub(gems_regexp, gems_result) } | |
end | |
def add_gem_silencer | |
add_silencer { |line| FORMATTED_GEMS_PATTERN.match?(line) } | |
end | |
def add_stdlib_silencer | |
add_silencer { |line| line.start_with?(RbConfig::CONFIG["rubylibdir"]) } | |
end | |
def filter_backtrace(backtrace) | |
@filters.each do |f| | |
backtrace = backtrace.map { |line| f.call(line) } | |
end | |
backtrace | |
end | |
def silence(backtrace) | |
@silencers.each do |s| | |
backtrace = backtrace.reject { |line| s.call(line) } | |
end | |
backtrace | |
end | |
def noise(backtrace) | |
backtrace.select do |line| | |
@silencers.any? do |s| | |
s.call(line) | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/benchmarkable" | |
require "active_support/dependencies" | |
require "active_support/descendants_tracker" | |
require "active_support/time" | |
require "active_support/core_ext/class/subclasses" | |
require "active_record/log_subscriber" | |
require "active_record/explain_subscriber" | |
require "active_record/relation/delegation" | |
require "active_record/attributes" | |
require "active_record/type_caster" | |
require "active_record/database_configurations" | |
module ActiveRecord # :nodoc: | |
# = Active Record | |
# | |
# Active Record objects don't specify their attributes directly, but rather infer them from | |
# the table definition with which they're linked. Adding, removing, and changing attributes | |
# and their type is done directly in the database. Any change is instantly reflected in the | |
# Active Record objects. The mapping that binds a given Active Record class to a certain | |
# database table will happen automatically in most common cases, but can be overwritten for the uncommon ones. | |
# | |
# See the mapping rules in table_name and the full example in link:files/activerecord/README_rdoc.html for more insight. | |
# | |
# == Creation | |
# | |
# Active Records accept constructor parameters either in a hash or as a block. The hash | |
# method is especially useful when you're receiving the data from somewhere else, like an | |
# HTTP request. It works like this: | |
# | |
# user = User.new(name: "David", occupation: "Code Artist") | |
# user.name # => "David" | |
# | |
# You can also use block initialization: | |
# | |
# user = User.new do |u| | |
# u.name = "David" | |
# u.occupation = "Code Artist" | |
# end | |
# | |
# And of course you can just create a bare object and specify the attributes after the fact: | |
# | |
# user = User.new | |
# user.name = "David" | |
# user.occupation = "Code Artist" | |
# | |
# == Conditions | |
# | |
# Conditions can either be specified as a string, array, or hash representing the WHERE-part of an SQL statement. | |
# The array form is to be used when the condition input is tainted and requires sanitization. The string form can | |
# be used for statements that don't involve tainted data. The hash form works much like the array form, except | |
# only equality and range is possible. Examples: | |
# | |
# class User < ActiveRecord::Base | |
# def self.authenticate_unsafely(user_name, password) | |
# where("user_name = '#{user_name}' AND password = '#{password}'").first | |
# end | |
# | |
# def self.authenticate_safely(user_name, password) | |
# where("user_name = ? AND password = ?", user_name, password).first | |
# end | |
# | |
# def self.authenticate_safely_simply(user_name, password) | |
# where(user_name: user_name, password: password).first | |
# end | |
# end | |
# | |
# The <tt>authenticate_unsafely</tt> method inserts the parameters directly into the query | |
# and is thus susceptible to SQL-injection attacks if the <tt>user_name</tt> and +password+ | |
# parameters come directly from an HTTP request. The <tt>authenticate_safely</tt> and | |
# <tt>authenticate_safely_simply</tt> both will sanitize the <tt>user_name</tt> and +password+ | |
# before inserting them in the query, which will ensure that an attacker can't escape the | |
# query and fake the login (or worse). | |
# | |
# When using multiple parameters in the conditions, it can easily become hard to read exactly | |
# what the fourth or fifth question mark is supposed to represent. In those cases, you can | |
# resort to named bind variables instead. That's done by replacing the question marks with | |
# symbols and supplying a hash with values for the matching symbol keys: | |
# | |
# Company.where( | |
# "id = :id AND name = :name AND division = :division AND created_at > :accounting_date", | |
# { id: 3, name: "37signals", division: "First", accounting_date: '2005-01-01' } | |
# ).first | |
# | |
# Similarly, a simple hash without a statement will generate conditions based on equality with the SQL AND | |
# operator. For instance: | |
# | |
# Student.where(first_name: "Harvey", status: 1) | |
# Student.where(params[:student]) | |
# | |
# A range may be used in the hash to use the SQL BETWEEN operator: | |
# | |
# Student.where(grade: 9..12) | |
# | |
# An array may be used in the hash to use the SQL IN operator: | |
# | |
# Student.where(grade: [9,11,12]) | |
# | |
# When joining tables, nested hashes or keys written in the form 'table_name.column_name' | |
# can be used to qualify the table name of a particular condition. For instance: | |
# | |
# Student.joins(:schools).where(schools: { category: 'public' }) | |
# Student.joins(:schools).where('schools.category' => 'public' ) | |
# | |
# == Overwriting default accessors | |
# | |
# All column values are automatically available through basic accessors on the Active Record | |
# object, but sometimes you want to specialize this behavior. This can be done by overwriting | |
# the default accessors (using the same name as the attribute) and calling | |
# +super+ to actually change things. | |
# | |
# class Song < ActiveRecord::Base | |
# # Uses an integer of seconds to hold the length of the song | |
# | |
# def length=(minutes) | |
# super(minutes.to_i * 60) | |
# end | |
# | |
# def length | |
# super / 60 | |
# end | |
# end | |
# | |
# == Attribute query methods | |
# | |
# In addition to the basic accessors, query methods are also automatically available on the Active Record object. | |
# Query methods allow you to test whether an attribute value is present. | |
# Additionally, when dealing with numeric values, a query method will return false if the value is zero. | |
# | |
# For example, an Active Record User with the <tt>name</tt> attribute has a <tt>name?</tt> method that you can call | |
# to determine whether the user has a name: | |
# | |
# user = User.new(name: "David") | |
# user.name? # => true | |
# | |
# anonymous = User.new(name: "") | |
# anonymous.name? # => false | |
# | |
# Query methods will also respect any overrides of default accessors: | |
# | |
# class User | |
# # Has admin boolean column | |
# def admin | |
# false | |
# end | |
# end | |
# | |
# user.update(admin: true) | |
# | |
# user.read_attribute(:admin) # => true, gets the column value | |
# user[:admin] # => true, also gets the column value | |
# | |
# user.admin # => false, due to the getter override | |
# user.admin? # => false, due to the getter override | |
# | |
# == Accessing attributes before they have been typecasted | |
# | |
# Sometimes you want to be able to read the raw attribute data without having the column-determined | |
# typecast run its course first. That can be done by using the <tt><attribute>_before_type_cast</tt> | |
# accessors that all attributes have. For example, if your Account model has a <tt>balance</tt> attribute, | |
# you can call <tt>account.balance_before_type_cast</tt> or <tt>account.id_before_type_cast</tt>. | |
# | |
# This is especially useful in validation situations where the user might supply a string for an | |
# integer field and you want to display the original string back in an error message. Accessing the | |
# attribute normally would typecast the string to 0, which isn't what you want. | |
# | |
# == Dynamic attribute-based finders | |
# | |
# Dynamic attribute-based finders are a mildly deprecated way of getting (and/or creating) objects | |
# by simple queries without turning to SQL. They work by appending the name of an attribute | |
# to <tt>find_by_</tt> like <tt>Person.find_by_user_name</tt>. | |
# Instead of writing <tt>Person.find_by(user_name: user_name)</tt>, you can use | |
# <tt>Person.find_by_user_name(user_name)</tt>. | |
# | |
# It's possible to add an exclamation point (!) on the end of the dynamic finders to get them to raise an | |
# ActiveRecord::RecordNotFound error if they do not return any records, | |
# like <tt>Person.find_by_last_name!</tt>. | |
# | |
# It's also possible to use multiple attributes in the same <tt>find_by_</tt> by separating them with | |
# "_and_". | |
# | |
# Person.find_by(user_name: user_name, password: password) | |
# Person.find_by_user_name_and_password(user_name, password) # with dynamic finder | |
# | |
# It's even possible to call these dynamic finder methods on relations and named scopes. | |
# | |
# Payment.order("created_on").find_by_amount(50) | |
# | |
# == Saving arrays, hashes, and other non-mappable objects in text columns | |
# | |
# Active Record can serialize any object in text columns using YAML. To do so, you must | |
# specify this with a call to the class method | |
# {serialize}[rdoc-ref:AttributeMethods::Serialization::ClassMethods#serialize]. | |
# This makes it possible to store arrays, hashes, and other non-mappable objects without doing | |
# any additional work. | |
# | |
# class User < ActiveRecord::Base | |
# serialize :preferences | |
# end | |
# | |
# user = User.create(preferences: { "background" => "black", "display" => large }) | |
# User.find(user.id).preferences # => { "background" => "black", "display" => large } | |
# | |
# You can also specify a class option as the second parameter that'll raise an exception | |
# if a serialized object is retrieved as a descendant of a class not in the hierarchy. | |
# | |
# class User < ActiveRecord::Base | |
# serialize :preferences, Hash | |
# end | |
# | |
# user = User.create(preferences: %w( one two three )) | |
# User.find(user.id).preferences # raises SerializationTypeMismatch | |
# | |
# When you specify a class option, the default value for that attribute will be a new | |
# instance of that class. | |
# | |
# class User < ActiveRecord::Base | |
# serialize :preferences, OpenStruct | |
# end | |
# | |
# user = User.new | |
# user.preferences.theme_color = "red" | |
# | |
# | |
# == Single table inheritance | |
# | |
# Active Record allows inheritance by storing the name of the class in a | |
# column that is named "type" by default. See ActiveRecord::Inheritance for | |
# more details. | |
# | |
# == Connection to multiple databases in different models | |
# | |
# Connections are usually created through | |
# {ActiveRecord::Base.establish_connection}[rdoc-ref:ConnectionHandling#establish_connection] and retrieved | |
# by ActiveRecord::Base.connection. All classes inheriting from ActiveRecord::Base will use this | |
# connection. But you can also set a class-specific connection. For example, if Course is an | |
# ActiveRecord::Base, but resides in a different database, you can just say <tt>Course.establish_connection</tt> | |
# and Course and all of its subclasses will use this connection instead. | |
# | |
# This feature is implemented by keeping a connection pool in ActiveRecord::Base that is | |
# a hash indexed by the class. If a connection is requested, the | |
# {ActiveRecord::Base.retrieve_connection}[rdoc-ref:ConnectionHandling#retrieve_connection] method | |
# will go up the class-hierarchy until a connection is found in the connection pool. | |
# | |
# == Exceptions | |
# | |
# * ActiveRecordError - Generic error class and superclass of all other errors raised by Active Record. | |
# * AdapterNotSpecified - The configuration hash used in | |
# {ActiveRecord::Base.establish_connection}[rdoc-ref:ConnectionHandling#establish_connection] | |
# didn't include an <tt>:adapter</tt> key. | |
# * AdapterNotFound - The <tt>:adapter</tt> key used in | |
# {ActiveRecord::Base.establish_connection}[rdoc-ref:ConnectionHandling#establish_connection] | |
# specified a non-existent adapter | |
# (or a bad spelling of an existing one). | |
# * AssociationTypeMismatch - The object assigned to the association wasn't of the type | |
# specified in the association definition. | |
# * AttributeAssignmentError - An error occurred while doing a mass assignment through the | |
# {ActiveRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method. | |
# You can inspect the +attribute+ property of the exception object to determine which attribute | |
# triggered the error. | |
# * ConnectionNotEstablished - No connection has been established. | |
# Use {ActiveRecord::Base.establish_connection}[rdoc-ref:ConnectionHandling#establish_connection] before querying. | |
# * MultiparameterAssignmentErrors - Collection of errors that occurred during a mass assignment using the | |
# {ActiveRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method. | |
# The +errors+ property of this exception contains an array of | |
# AttributeAssignmentError | |
# objects that should be inspected to determine which attributes triggered the errors. | |
# * RecordInvalid - raised by {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] and | |
# {ActiveRecord::Base.create!}[rdoc-ref:Persistence::ClassMethods#create!] | |
# when the record is invalid. | |
# * RecordNotFound - No record responded to the {ActiveRecord::Base.find}[rdoc-ref:FinderMethods#find] method. | |
# Either the row with the given ID doesn't exist or the row didn't meet the additional restrictions. | |
# Some {ActiveRecord::Base.find}[rdoc-ref:FinderMethods#find] calls do not raise this exception to signal | |
# nothing was found, please check its documentation for further details. | |
# * SerializationTypeMismatch - The serialized object wasn't of the class specified as the second parameter. | |
# * StatementInvalid - The database server rejected the SQL statement. The precise error is added in the message. | |
# | |
# *Note*: The attributes listed are class-level attributes (accessible from both the class and instance level). | |
# So it's possible to assign a logger to the class through <tt>Base.logger=</tt> which will then be used by all | |
# instances in the current object space. | |
class Base | |
extend ActiveModel::Naming | |
extend ActiveSupport::Benchmarkable | |
extend ActiveSupport::DescendantsTracker | |
extend ConnectionHandling | |
extend QueryCache::ClassMethods | |
extend Querying | |
extend Translation | |
extend DynamicMatchers | |
extend DelegatedType | |
extend Explain | |
extend Enum | |
extend Delegation::DelegateCache | |
extend Aggregations::ClassMethods | |
include Core | |
include Persistence | |
include ReadonlyAttributes | |
include ModelSchema | |
include Inheritance | |
include Scoping | |
include Sanitization | |
include AttributeAssignment | |
include ActiveModel::Conversion | |
include Integration | |
include Validations | |
include CounterCache | |
include Attributes | |
include Locking::Optimistic | |
include Locking::Pessimistic | |
include AttributeMethods | |
include Callbacks | |
include Timestamp | |
include Associations | |
include SecurePassword | |
include AutosaveAssociation | |
include NestedAttributes | |
include Transactions | |
include TouchLater | |
include NoTouching | |
include Reflection | |
include Serialization | |
include Store | |
include SecureToken | |
include SignedId | |
include Suppressor | |
include Encryption::EncryptableRecord | |
end | |
ActiveSupport.run_load_hooks(:active_record, Base) | |
end |
# frozen_string_literal: true | |
class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController # :nodoc: | |
include ActiveStorage::SetBlob | |
before_action :set_representation | |
private | |
def blob_scope | |
ActiveStorage::Blob.scope_for_strict_loading | |
end | |
def set_representation | |
@representation = @blob.representation(params[:variation_key]).processed | |
rescue ActiveSupport::MessageVerifier::InvalidSignature | |
head :not_found | |
end | |
end |
# frozen_string_literal: true | |
class ActiveStorage::BaseJob < ActiveJob::Base | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/bird" | |
class BasePreventWritesTest < ActiveRecord::TestCase | |
if !in_memory_db? | |
test "creating a record raises if preventing writes" do | |
ActiveRecord::Base.while_preventing_writes do | |
error = assert_raises ActiveRecord::ReadOnlyError do | |
Bird.create! name: "Bluejay" | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, error.message | |
end | |
end | |
test "updating a record raises if preventing writes" do | |
bird = Bird.create! name: "Bluejay" | |
ActiveRecord::Base.while_preventing_writes do | |
error = assert_raises ActiveRecord::ReadOnlyError do | |
bird.update! name: "Robin" | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: UPDATE /, error.message | |
end | |
end | |
test "deleting a record raises if preventing writes" do | |
bird = Bird.create! name: "Bluejay" | |
ActiveRecord::Base.while_preventing_writes do | |
error = assert_raises ActiveRecord::ReadOnlyError do | |
bird.destroy! | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: DELETE /, error.message | |
end | |
end | |
test "selecting a record does not raise if preventing writes" do | |
bird = Bird.create! name: "Bluejay" | |
ActiveRecord::Base.while_preventing_writes do | |
assert_equal bird, Bird.where(name: "Bluejay").last | |
end | |
end | |
test "an explain query does not raise if preventing writes" do | |
Bird.create!(name: "Bluejay") | |
ActiveRecord::Base.while_preventing_writes do | |
assert_queries(2) { Bird.where(name: "Bluejay").explain } | |
end | |
end | |
test "an empty transaction does not raise if preventing writes" do | |
ActiveRecord::Base.while_preventing_writes do | |
assert_queries(2, ignore_none: true) do | |
Bird.transaction do | |
ActiveRecord::Base.connection.materialize_transactions | |
end | |
end | |
end | |
end | |
test "preventing writes applies to all connections in block" do | |
ActiveRecord::Base.while_preventing_writes do | |
conn1_error = assert_raises ActiveRecord::ReadOnlyError do | |
assert_equal ActiveRecord::Base.connection, Bird.connection | |
assert_not_equal ARUnit2Model.connection, Bird.connection | |
Bird.create!(name: "Bluejay") | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, conn1_error.message | |
end | |
ActiveRecord::Base.while_preventing_writes do | |
conn2_error = assert_raises ActiveRecord::ReadOnlyError do | |
assert_not_equal ActiveRecord::Base.connection, Professor.connection | |
assert_equal ARUnit2Model.connection, Professor.connection | |
Professor.create!(name: "Professor Bluejay") | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, conn2_error.message | |
end | |
end | |
test "current_preventing_writes" do | |
ActiveRecord::Base.while_preventing_writes do | |
assert ActiveRecord::Base.current_preventing_writes, "expected connection current_preventing_writes to return true" | |
end | |
end | |
end | |
class BasePreventWritesLegacyTest < ActiveRecord::TestCase | |
def setup | |
@old_value = ActiveRecord.legacy_connection_handling | |
ActiveRecord.legacy_connection_handling = true | |
ActiveRecord::Base.establish_connection :arunit | |
ARUnit2Model.establish_connection :arunit2 | |
end | |
def teardown | |
clean_up_legacy_connection_handlers | |
ActiveRecord.legacy_connection_handling = @old_value | |
end | |
if !in_memory_db? | |
test "creating a record raises if preventing writes" do | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
error = assert_raises ActiveRecord::ReadOnlyError do | |
Bird.create! name: "Bluejay" | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, error.message | |
end | |
end | |
test "updating a record raises if preventing writes" do | |
bird = Bird.create! name: "Bluejay" | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
error = assert_raises ActiveRecord::ReadOnlyError do | |
bird.update! name: "Robin" | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: UPDATE /, error.message | |
end | |
end | |
test "deleting a record raises if preventing writes" do | |
bird = Bird.create! name: "Bluejay" | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
error = assert_raises ActiveRecord::ReadOnlyError do | |
bird.destroy! | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: DELETE /, error.message | |
end | |
end | |
test "selecting a record does not raise if preventing writes" do | |
bird = Bird.create! name: "Bluejay" | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
assert_equal bird, Bird.where(name: "Bluejay").last | |
end | |
end | |
test "an explain query does not raise if preventing writes" do | |
Bird.create!(name: "Bluejay") | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
assert_queries(2) { Bird.where(name: "Bluejay").explain } | |
end | |
end | |
test "an empty transaction does not raise if preventing writes" do | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
assert_queries(2, ignore_none: true) do | |
Bird.transaction do | |
ActiveRecord::Base.connection.materialize_transactions | |
end | |
end | |
end | |
end | |
test "preventing writes applies to all connections on a handler" do | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
conn1_error = assert_raises ActiveRecord::ReadOnlyError do | |
assert_equal ActiveRecord::Base.connection, Bird.connection | |
assert_not_equal ARUnit2Model.connection, Bird.connection | |
Bird.create!(name: "Bluejay") | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, conn1_error.message | |
end | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
conn2_error = assert_raises ActiveRecord::ReadOnlyError do | |
assert_not_equal ActiveRecord::Base.connection, Professor.connection | |
assert_equal ARUnit2Model.connection, Professor.connection | |
Professor.create!(name: "Professor Bluejay") | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, conn2_error.message | |
end | |
end | |
test "preventing writes with multiple handlers" do | |
ActiveRecord::Base.connects_to(database: { writing: :arunit, reading: :arunit }) | |
ActiveRecord::Base.connected_to(role: :writing) do | |
conn1_error = assert_raises ActiveRecord::ReadOnlyError do | |
assert_equal :writing, ActiveRecord::Base.current_role | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
Bird.create!(name: "Bluejay") | |
end | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, conn1_error.message | |
end | |
ActiveRecord::Base.connected_to(role: :reading) do | |
conn2_error = assert_raises ActiveRecord::ReadOnlyError do | |
assert_equal :reading, ActiveRecord::Base.current_role | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
Bird.create!(name: "Bluejay") | |
end | |
end | |
assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, conn2_error.message | |
end | |
end | |
test "current_preventing_writes" do | |
ActiveRecord::Base.connection_handler.while_preventing_writes do | |
assert ActiveRecord::Base.current_preventing_writes, "expected connection current_preventing_writes to return true" | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/post" | |
require "models/author" | |
require "models/topic" | |
require "models/reply" | |
require "models/category" | |
require "models/categorization" | |
require "models/company" | |
require "models/customer" | |
require "models/developer" | |
require "models/computer" | |
require "models/project" | |
require "models/default" | |
require "models/auto_id" | |
require "models/column_name" | |
require "models/subscriber" | |
require "models/comment" | |
require "models/minimalistic" | |
require "models/warehouse_thing" | |
require "models/parrot" | |
require "models/person" | |
require "models/edge" | |
require "models/joke" | |
require "models/bird" | |
require "models/car" | |
require "models/bulb" | |
require "models/pet" | |
require "models/owner" | |
require "concurrent/atomic/count_down_latch" | |
require "active_support/core_ext/enumerable" | |
class FirstAbstractClass < ActiveRecord::Base | |
self.abstract_class = true | |
connects_to database: { writing: :arunit, reading: :arunit } | |
end | |
class SecondAbstractClass < FirstAbstractClass | |
self.abstract_class = true | |
connects_to database: { writing: :arunit, reading: :arunit } | |
end | |
class ThirdAbstractClass < SecondAbstractClass | |
self.abstract_class = true | |
end | |
class Photo < SecondAbstractClass; end | |
class Smarts < ActiveRecord::Base; end | |
class CreditCard < ActiveRecord::Base | |
class PinNumber < ActiveRecord::Base | |
class CvvCode < ActiveRecord::Base; end | |
class SubCvvCode < CvvCode; end | |
end | |
class SubPinNumber < PinNumber; end | |
class Brand < Category; end | |
end | |
class MasterCreditCard < ActiveRecord::Base; end | |
class NonExistentTable < ActiveRecord::Base; end | |
class TestOracleDefault < ActiveRecord::Base; end | |
class ReadonlyTitlePost < Post | |
attr_readonly :title | |
end | |
class Weird < ActiveRecord::Base; end | |
class LintTest < ActiveRecord::TestCase | |
include ActiveModel::Lint::Tests | |
class LintModel < ActiveRecord::Base; end | |
def setup | |
@model = LintModel.new | |
end | |
end | |
class BasicsTest < ActiveRecord::TestCase | |
fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, "warehouse-things", :authors, :author_addresses, :categorizations, :categories, :posts | |
def test_generated_association_methods_module_name | |
mod = Post.send(:generated_association_methods) | |
assert_equal "Post::GeneratedAssociationMethods", mod.inspect | |
end | |
def test_generated_relation_methods_module_name | |
mod = Post.send(:generated_relation_methods) | |
assert_equal "Post::GeneratedRelationMethods", mod.inspect | |
end | |
def test_arel_attribute_normalization | |
assert_equal Post.arel_table["body"], Post.arel_table[:body] | |
assert_equal Post.arel_table["body"], Post.arel_table[:text] | |
end | |
def test_incomplete_schema_loading | |
topic = Topic.first | |
payload = { foo: 42 } | |
topic.update!(content: payload) | |
Topic.reset_column_information | |
Topic.connection.stub(:lookup_cast_type_from_column, ->(_) { raise "Some Error" }) do | |
assert_raises RuntimeError do | |
Topic.columns_hash | |
end | |
end | |
assert_equal payload, Topic.first.content | |
end | |
def test_column_names_are_escaped | |
conn = ActiveRecord::Base.connection | |
classname = conn.class.name[/[^:]*$/] | |
badchar = { | |
"SQLite3Adapter" => '"', | |
"Mysql2Adapter" => "`", | |
"PostgreSQLAdapter" => '"', | |
"OracleAdapter" => '"', | |
}.fetch(classname) { | |
raise "need a bad char for #{classname}" | |
} | |
quoted = conn.quote_column_name "foo#{badchar}bar" | |
if current_adapter?(:OracleAdapter) | |
# Oracle does not allow double quotes in table and column names at all | |
# therefore quoting removes them | |
assert_equal("#{badchar}foobar#{badchar}", quoted) | |
else | |
assert_equal("#{badchar}foo#{badchar * 2}bar#{badchar}", quoted) | |
end | |
end | |
def test_columns_should_obey_set_primary_key | |
pk = Subscriber.columns_hash[Subscriber.primary_key] | |
assert_equal "nick", pk.name, "nick should be primary key" | |
end | |
def test_primary_key_with_no_id | |
assert_nil Edge.primary_key | |
end | |
def test_primary_key_and_references_columns_should_be_identical_type | |
pk = Author.columns_hash["id"] | |
ref = Post.columns_hash["author_id"] | |
assert_equal pk.sql_type, ref.sql_type | |
end | |
def test_many_mutations | |
car = Car.new name: "<3<3<3" | |
car.engines_count = 0 | |
20_000.times { car.engines_count += 1 } | |
assert car.save | |
end | |
def test_limit_without_comma | |
assert_equal 1, Topic.limit("1").to_a.length | |
assert_equal 1, Topic.limit(1).to_a.length | |
end | |
def test_limit_should_take_value_from_latest_limit | |
assert_equal 1, Topic.limit(2).limit(1).to_a.length | |
end | |
def test_invalid_limit | |
assert_raises(ArgumentError) do | |
Topic.limit("asdfadf").to_a | |
end | |
end | |
def test_limit_should_sanitize_sql_injection_for_limit_without_commas | |
assert_raises(ArgumentError) do | |
Topic.limit("1 select * from schema").to_a | |
end | |
end | |
def test_limit_should_sanitize_sql_injection_for_limit_with_commas | |
assert_raises(ArgumentError) do | |
Topic.limit("1, 7 procedure help()").to_a | |
end | |
end | |
def test_select_symbol | |
topic_ids = Topic.select(:id).map(&:id).sort | |
assert_equal Topic.pluck(:id).sort, topic_ids | |
end | |
def test_table_exists | |
assert_not_predicate NonExistentTable, :table_exists? | |
assert_predicate Topic, :table_exists? | |
end | |
def test_preserving_date_objects | |
# Oracle enhanced adapter allows to define Date attributes in model class (see topic.rb) | |
assert_kind_of( | |
Date, Topic.find(1).last_read, | |
"The last_read attribute should be of the Date class" | |
) | |
end | |
def test_previously_changed | |
topic = Topic.first | |
topic.title = "<3<3<3" | |
assert_equal({}, topic.previous_changes) | |
topic.save! | |
expected = ["The First Topic", "<3<3<3"] | |
assert_equal(expected, topic.previous_changes["title"]) | |
end | |
def test_previously_changed_dup | |
topic = Topic.first | |
topic.title = "<3<3<3" | |
topic.save! | |
t2 = topic.dup | |
assert_equal(topic.previous_changes, t2.previous_changes) | |
topic.title = "lolwut" | |
topic.save! | |
assert_not_equal(topic.previous_changes, t2.previous_changes) | |
end | |
def test_preserving_time_objects | |
assert_kind_of( | |
Time, Topic.find(1).bonus_time, | |
"The bonus_time attribute should be of the Time class" | |
) | |
assert_kind_of( | |
Time, Topic.find(1).written_on, | |
"The written_on attribute should be of the Time class" | |
) | |
# For adapters which support microsecond resolution. | |
if supports_datetime_with_precision? | |
assert_equal 11, Topic.find(1).written_on.sec | |
assert_equal 223300, Topic.find(1).written_on.usec | |
assert_equal 9900, Topic.find(2).written_on.usec | |
assert_equal 129346, Topic.find(3).written_on.usec | |
end | |
end | |
def test_preserving_time_objects_with_local_time_conversion_to_default_timezone_utc | |
with_env_tz eastern_time_zone do | |
with_timezone_config default: :utc do | |
time = Time.local(2000) | |
topic = Topic.create("written_on" => time) | |
saved_time = Topic.find(topic.id).reload.written_on | |
assert_equal time, saved_time | |
assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "EST"], time.to_a | |
assert_equal [0, 0, 5, 1, 1, 2000, 6, 1, false, "UTC"], saved_time.to_a | |
end | |
end | |
end | |
def test_preserving_time_objects_with_time_with_zone_conversion_to_default_timezone_utc | |
with_env_tz eastern_time_zone do | |
with_timezone_config default: :utc do | |
Time.use_zone "Central Time (US & Canada)" do | |
time = Time.zone.local(2000) | |
topic = Topic.create("written_on" => time) | |
saved_time = Topic.find(topic.id).reload.written_on | |
assert_equal time, saved_time | |
assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "CST"], time.to_a | |
assert_equal [0, 0, 6, 1, 1, 2000, 6, 1, false, "UTC"], saved_time.to_a | |
end | |
end | |
end | |
end | |
def test_preserving_time_objects_with_utc_time_conversion_to_default_timezone_local | |
with_env_tz eastern_time_zone do | |
with_timezone_config default: :local do | |
time = Time.utc(2000) | |
topic = Topic.create("written_on" => time) | |
saved_time = Topic.find(topic.id).reload.written_on | |
assert_equal time, saved_time | |
assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "UTC"], time.to_a | |
assert_equal [0, 0, 19, 31, 12, 1999, 5, 365, false, "EST"], saved_time.to_a | |
end | |
end | |
end | |
def test_preserving_time_objects_with_time_with_zone_conversion_to_default_timezone_local | |
with_env_tz eastern_time_zone do | |
with_timezone_config default: :local do | |
Time.use_zone "Central Time (US & Canada)" do | |
time = Time.zone.local(2000) | |
topic = Topic.create("written_on" => time) | |
saved_time = Topic.find(topic.id).reload.written_on | |
assert_equal time, saved_time | |
assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "CST"], time.to_a | |
assert_equal [0, 0, 1, 1, 1, 2000, 6, 1, false, "EST"], saved_time.to_a | |
end | |
end | |
end | |
end | |
def test_time_zone_aware_attribute_with_default_timezone_utc_on_utc_can_be_created | |
with_env_tz eastern_time_zone do | |
with_timezone_config aware_attributes: true, default: :utc, zone: "UTC" do | |
pet = Pet.create(name: "Bidu") | |
assert_predicate pet, :persisted? | |
saved_pet = Pet.find(pet.id) | |
assert_not_nil saved_pet.created_at | |
assert_not_nil saved_pet.updated_at | |
end | |
end | |
end | |
def eastern_time_zone | |
if Gem.win_platform? | |
"EST5EDT" | |
else | |
"America/New_York" | |
end | |
end | |
def test_custom_mutator | |
topic = Topic.find(1) | |
# This mutator is protected in the class definition | |
topic.send(:approved=, true) | |
assert topic.instance_variable_get("@custom_approved") | |
end | |
def test_initialize_with_attributes | |
topic = Topic.new( | |
"title" => "initialized from attributes", "written_on" => "2003-12-12 23:23") | |
assert_equal("initialized from attributes", topic.title) | |
end | |
def test_initialize_with_invalid_attribute | |
ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do | |
Topic.new("title" => "test", | |
"written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00") | |
end | |
assert_equal(1, ex.errors.size) | |
assert_equal("written_on", ex.errors[0].attribute) | |
end | |
def test_create_after_initialize_without_block | |
cb = CustomBulb.create(name: "Dude") | |
assert_equal("Dude", cb.name) | |
assert_equal(true, cb.frickinawesome) | |
end | |
def test_create_after_initialize_with_block | |
cb = CustomBulb.create { |c| c.name = "Dude" } | |
assert_equal("Dude", cb.name) | |
assert_equal(true, cb.frickinawesome) | |
end | |
def test_create_after_initialize_with_array_param | |
cbs = CustomBulb.create([{ name: "Dude" }, { name: "Bob" }]) | |
assert_equal "Dude", cbs[0].name | |
assert_equal "Bob", cbs[1].name | |
assert cbs[0].frickinawesome | |
assert_not cbs[1].frickinawesome | |
end | |
def test_load | |
topics = Topic.all.merge!(order: "id").to_a | |
assert_equal(5, topics.size) | |
assert_equal(topics(:first).title, topics.first.title) | |
end | |
def test_load_with_condition | |
topics = Topic.all.merge!(where: "author_name = 'Mary'").to_a | |
assert_equal(1, topics.size) | |
assert_equal(topics(:second).title, topics.first.title) | |
end | |
GUESSED_CLASSES = [Category, Smarts, CreditCard, CreditCard::PinNumber, CreditCard::PinNumber::CvvCode, CreditCard::SubPinNumber, CreditCard::Brand, MasterCreditCard] | |
def test_table_name_guesses | |
assert_equal "topics", Topic.table_name | |
assert_equal "categories", Category.table_name | |
assert_equal "smarts", Smarts.table_name | |
assert_equal "credit_cards", CreditCard.table_name | |
assert_equal "credit_card_pin_numbers", CreditCard::PinNumber.table_name | |
assert_equal "credit_card_pin_number_cvv_codes", CreditCard::PinNumber::CvvCode.table_name | |
assert_equal "credit_card_pin_numbers", CreditCard::SubPinNumber.table_name | |
assert_equal "categories", CreditCard::Brand.table_name | |
assert_equal "master_credit_cards", MasterCreditCard.table_name | |
ensure | |
GUESSED_CLASSES.each(&:reset_table_name) | |
end | |
def test_singular_table_name_guesses | |
ActiveRecord::Base.pluralize_table_names = false | |
GUESSED_CLASSES.each(&:reset_table_name) | |
assert_equal "category", Category.table_name | |
assert_equal "smarts", Smarts.table_name | |
assert_equal "credit_card", CreditCard.table_name | |
assert_equal "credit_card_pin_number", CreditCard::PinNumber.table_name | |
assert_equal "credit_card_pin_number_cvv_code", CreditCard::PinNumber::CvvCode.table_name | |
assert_equal "credit_card_pin_number", CreditCard::SubPinNumber.table_name | |
assert_equal "category", CreditCard::Brand.table_name | |
assert_equal "master_credit_card", MasterCreditCard.table_name | |
ensure | |
ActiveRecord::Base.pluralize_table_names = true | |
GUESSED_CLASSES.each(&:reset_table_name) | |
end | |
def test_table_name_guesses_with_prefixes_and_suffixes | |
ActiveRecord::Base.table_name_prefix = "test_" | |
Category.reset_table_name | |
assert_equal "test_categories", Category.table_name | |
ActiveRecord::Base.table_name_suffix = "_test" | |
Category.reset_table_name | |
assert_equal "test_categories_test", Category.table_name | |
ActiveRecord::Base.table_name_prefix = "" | |
Category.reset_table_name | |
assert_equal "categories_test", Category.table_name | |
ActiveRecord::Base.table_name_suffix = "" | |
Category.reset_table_name | |
assert_equal "categories", Category.table_name | |
ensure | |
ActiveRecord::Base.table_name_prefix = "" | |
ActiveRecord::Base.table_name_suffix = "" | |
GUESSED_CLASSES.each(&:reset_table_name) | |
end | |
def test_singular_table_name_guesses_with_prefixes_and_suffixes | |
ActiveRecord::Base.pluralize_table_names = false | |
ActiveRecord::Base.table_name_prefix = "test_" | |
Category.reset_table_name | |
assert_equal "test_category", Category.table_name | |
ActiveRecord::Base.table_name_suffix = "_test" | |
Category.reset_table_name | |
assert_equal "test_category_test", Category.table_name | |
ActiveRecord::Base.table_name_prefix = "" | |
Category.reset_table_name | |
assert_equal "category_test", Category.table_name | |
ActiveRecord::Base.table_name_suffix = "" | |
Category.reset_table_name | |
assert_equal "category", Category.table_name | |
ensure | |
ActiveRecord::Base.pluralize_table_names = true | |
ActiveRecord::Base.table_name_prefix = "" | |
ActiveRecord::Base.table_name_suffix = "" | |
GUESSED_CLASSES.each(&:reset_table_name) | |
end | |
def test_table_name_guesses_with_inherited_prefixes_and_suffixes | |
GUESSED_CLASSES.each(&:reset_table_name) | |
CreditCard.table_name_prefix = "test_" | |
CreditCard.reset_table_name | |
Category.reset_table_name | |
assert_equal "test_credit_cards", CreditCard.table_name | |
assert_equal "categories", Category.table_name | |
CreditCard.table_name_suffix = "_test" | |
CreditCard.reset_table_name | |
Category.reset_table_name | |
assert_equal "test_credit_cards_test", CreditCard.table_name | |
assert_equal "categories", Category.table_name | |
CreditCard.table_name_prefix = "" | |
CreditCard.reset_table_name | |
Category.reset_table_name | |
assert_equal "credit_cards_test", CreditCard.table_name | |
assert_equal "categories", Category.table_name | |
CreditCard.table_name_suffix = "" | |
CreditCard.reset_table_name | |
Category.reset_table_name | |
assert_equal "credit_cards", CreditCard.table_name | |
assert_equal "categories", Category.table_name | |
ensure | |
CreditCard.table_name_prefix = "" | |
CreditCard.table_name_suffix = "" | |
GUESSED_CLASSES.each(&:reset_table_name) | |
end | |
def test_singular_table_name_guesses_for_individual_table | |
Post.pluralize_table_names = false | |
Post.reset_table_name | |
assert_equal "post", Post.table_name | |
assert_equal "categories", Category.table_name | |
ensure | |
Post.pluralize_table_names = true | |
Post.reset_table_name | |
end | |
def test_table_name_based_on_model_name | |
assert_equal "posts", PostRecord.table_name | |
end | |
def test_null_fields | |
assert_nil Topic.find(1).parent_id | |
assert_nil Topic.create("title" => "Hey you").parent_id | |
end | |
def test_default_values | |
topic = Topic.new | |
assert_predicate topic, :approved? | |
assert_nil topic.written_on | |
assert_nil topic.bonus_time | |
assert_nil topic.last_read | |
topic.save | |
topic = Topic.find(topic.id) | |
assert_predicate topic, :approved? | |
assert_nil topic.last_read | |
# Oracle has some funky default handling, so it requires a bit of | |
# extra testing. See ticket #2788. | |
if current_adapter?(:OracleAdapter) | |
test = TestOracleDefault.new | |
assert_equal "X", test.test_char | |
assert_equal "hello", test.test_string | |
assert_equal 3, test.test_int | |
end | |
end | |
# Oracle does not have a TIME datatype. | |
unless current_adapter?(:OracleAdapter) | |
def test_utc_as_time_zone | |
with_timezone_config default: :utc do | |
attributes = { "bonus_time" => "5:42:00AM" } | |
topic = Topic.find(1) | |
topic.attributes = attributes | |
assert_equal Time.utc(2000, 1, 1, 5, 42, 0), topic.bonus_time | |
end | |
end | |
def test_utc_as_time_zone_and_new | |
with_timezone_config default: :utc do | |
attributes = { "bonus_time(1i)" => "2000", | |
"bonus_time(2i)" => "1", | |
"bonus_time(3i)" => "1", | |
"bonus_time(4i)" => "10", | |
"bonus_time(5i)" => "35", | |
"bonus_time(6i)" => "50" } | |
topic = Topic.new(attributes) | |
assert_equal Time.utc(2000, 1, 1, 10, 35, 50), topic.bonus_time | |
end | |
end | |
end | |
def test_default_values_on_empty_strings | |
topic = Topic.new | |
topic.approved = nil | |
topic.last_read = nil | |
topic.save | |
topic = Topic.find(topic.id) | |
assert_nil topic.last_read | |
assert_nil topic.approved | |
end | |
def test_equality | |
assert_equal Topic.find(1), Topic.find(2).topic | |
end | |
def test_find_by_slug | |
assert_equal Topic.find("1-meowmeow"), Topic.find(1) | |
end | |
def test_out_of_range_slugs | |
assert_equal [Topic.find(1)], Topic.where(id: ["1-meowmeow", "9223372036854775808-hello"]) | |
end | |
def test_find_by_slug_with_array | |
assert_equal Topic.find([1, 2]), Topic.find(["1-meowmeow", "2-hello"]) | |
assert_equal "The Second Topic of the day", Topic.find(["2-hello", "1-meowmeow"]).first.title | |
end | |
def test_find_by_slug_with_range | |
assert_equal Topic.where(id: "1-meowmeow".."2-hello"), Topic.where(id: 1..2) | |
end | |
def test_equality_of_new_records | |
assert_not_equal Topic.new, Topic.new | |
assert_equal false, Topic.new == Topic.new | |
end | |
def test_equality_of_destroyed_records | |
topic_1 = Topic.new(title: "test_1") | |
topic_1.save | |
topic_2 = Topic.find(topic_1.id) | |
topic_1.destroy | |
assert_equal topic_1, topic_2 | |
assert_equal topic_2, topic_1 | |
end | |
def test_equality_with_blank_ids | |
one = Subscriber.new(id: "") | |
two = Subscriber.new(id: "") | |
assert_equal one, two | |
end | |
def test_equality_of_relation_and_collection_proxy | |
car = Car.create! | |
car.bulbs.build | |
car.save | |
bulbs_of_car = Bulb.where(car_id: car.id) | |
assert_equal car.bulbs, bulbs_of_car, "CollectionProxy should be comparable with Relation" | |
assert_equal bulbs_of_car, car.bulbs, "Relation should be comparable with CollectionProxy" | |
end | |
def test_equality_of_relation_and_array | |
car = Car.create! | |
car.bulbs.build | |
car.save | |
bulbs_of_car = Bulb.where(car_id: car.id) | |
assert_equal bulbs_of_car, car.bulbs.to_a, "Relation should be comparable with Array" | |
end | |
def test_equality_of_relation_and_association_relation | |
car = Car.create! | |
car.bulbs.build | |
car.save | |
bulbs_of_car = Bulb.where(car_id: car.id) | |
assert_equal bulbs_of_car, car.bulbs.includes(:car), "Relation should be comparable with AssociationRelation" | |
assert_equal car.bulbs.includes(:car), bulbs_of_car, "AssociationRelation should be comparable with Relation" | |
end | |
def test_equality_of_collection_proxy_and_association_relation | |
car = Car.create! | |
car.bulbs.build | |
car.save | |
assert_equal car.bulbs, car.bulbs.includes(:car), "CollectionProxy should be comparable with AssociationRelation" | |
assert_equal car.bulbs.includes(:car), car.bulbs, "AssociationRelation should be comparable with CollectionProxy" | |
end | |
def test_hashing | |
assert_equal [ Topic.find(1) ], [ Topic.find(2).topic ] & [ Topic.find(1) ] | |
end | |
def test_successful_comparison_of_like_class_records | |
topic_1 = Topic.create! | |
topic_2 = Topic.create! | |
assert_equal [topic_2, topic_1].sort, [topic_1, topic_2] | |
end | |
def test_failed_comparison_of_unlike_class_records | |
assert_raises ArgumentError do | |
[ topics(:first), posts(:welcome) ].sort | |
end | |
end | |
def test_create_without_prepared_statement | |
topic = Topic.connection.unprepared_statement do | |
Topic.create(title: "foo") | |
end | |
assert_equal topic, Topic.find(topic.id) | |
end | |
def test_destroy_without_prepared_statement | |
topic = Topic.create(title: "foo") | |
Topic.connection.unprepared_statement do | |
Topic.find(topic.id).destroy | |
end | |
assert_nil Topic.find_by_id(topic.id) | |
end | |
def test_comparison_with_different_objects | |
topic = Topic.create | |
category = Category.create(name: "comparison") | |
assert_nil topic <=> category | |
end | |
def test_comparison_with_different_objects_in_array | |
topic = Topic.create | |
assert_raises(ArgumentError) do | |
[1, topic].sort | |
end | |
end | |
def test_readonly_attributes | |
assert_equal Set.new([ "title", "comments_count" ]), ReadonlyTitlePost.readonly_attributes | |
post = ReadonlyTitlePost.create(title: "cannot change this", body: "changeable") | |
post.reload | |
assert_equal "cannot change this", post.title | |
post.update(title: "try to change", body: "changed") | |
post.reload | |
assert_equal "cannot change this", post.title | |
assert_equal "changed", post.body | |
end | |
def test_unicode_column_name | |
Weird.reset_column_information | |
weird = Weird.create(なまえ: "たこ焼き仮面") | |
assert_equal "たこ焼き仮面", weird.なまえ | |
end | |
unless current_adapter?(:PostgreSQLAdapter) | |
def test_respect_internal_encoding | |
old_default_internal = Encoding.default_internal | |
silence_warnings { Encoding.default_internal = "EUC-JP" } | |
Weird.reset_column_information | |
assert_equal ["EUC-JP"], Weird.columns.map { |c| c.name.encoding.name }.uniq | |
ensure | |
silence_warnings { Encoding.default_internal = old_default_internal } | |
Weird.reset_column_information | |
end | |
end | |
def test_non_valid_identifier_column_name | |
weird = Weird.create("a$b" => "value") | |
weird.reload | |
assert_equal "value", weird.public_send("a$b") | |
assert_equal "value", weird.read_attribute("a$b") | |
weird.update_columns("a$b" => "value2") | |
weird.reload | |
assert_equal "value2", weird.public_send("a$b") | |
assert_equal "value2", weird.read_attribute("a$b") | |
end | |
def test_group_weirds_by_from | |
Weird.create("a$b" => "value", :from => "aaron") | |
count = Weird.group(Weird.arel_table[:from]).count | |
assert_equal 1, count["aaron"] | |
end | |
def test_attributes_on_dummy_time | |
# Oracle does not have a TIME datatype. | |
return true if current_adapter?(:OracleAdapter) | |
with_timezone_config default: :local do | |
attributes = { | |
"bonus_time" => "5:42:00AM" | |
} | |
topic = Topic.find(1) | |
topic.attributes = attributes | |
assert_equal Time.local(2000, 1, 1, 5, 42, 0), topic.bonus_time | |
topic.save! | |
assert_equal topic, Topic.find_by(attributes) | |
end | |
end | |
def test_attributes_on_dummy_time_with_invalid_time | |
# Oracle does not have a TIME datatype. | |
return true if current_adapter?(:OracleAdapter) | |
attributes = { | |
"bonus_time" => "not a time" | |
} | |
topic = Topic.find(1) | |
topic.attributes = attributes | |
assert_nil topic.bonus_time | |
end | |
def test_attributes | |
category = Category.new(name: "Ruby") | |
expected_attributes = category.attribute_names.index_with do |attribute_name| | |
category.public_send(attribute_name) | |
end | |
assert_instance_of Hash, category.attributes | |
assert_equal expected_attributes, category.attributes | |
end | |
def test_new_record_returns_boolean | |
assert_equal false, Topic.new.persisted? | |
assert_equal true, Topic.find(1).persisted? | |
end | |
def test_previously_new_record_returns_boolean | |
assert_equal false, Topic.new.previously_new_record? | |
assert_equal true, Topic.create.previously_new_record? | |
assert_equal false, Topic.find(1).previously_new_record? | |
end | |
def test_previously_persisted_returns_boolean | |
assert_equal false, Topic.new.previously_persisted? | |
assert_equal false, Topic.new.destroy.previously_persisted? | |
assert_equal false, Topic.first.previously_persisted? | |
assert_equal true, Topic.first.destroy.previously_persisted? | |
assert_equal true, Topic.first.delete.previously_persisted? | |
end | |
def test_dup | |
topic = Topic.find(1) | |
duped_topic = nil | |
assert_nothing_raised { duped_topic = topic.dup } | |
assert_equal topic.title, duped_topic.title | |
assert_not_predicate duped_topic, :persisted? | |
# test if the attributes have been duped | |
topic.title = "a" | |
duped_topic.title = "b" | |
assert_equal "a", topic.title | |
assert_equal "b", duped_topic.title | |
# test if the attribute values have been duped | |
duped_topic = topic.dup | |
duped_topic.title.replace "c" | |
assert_equal "a", topic.title | |
# test if attributes set as part of after_initialize are duped correctly | |
assert_equal topic.author_email_address, duped_topic.author_email_address | |
# test if saved clone object differs from original | |
duped_topic.save | |
assert_predicate duped_topic, :persisted? | |
assert_not_equal duped_topic.id, topic.id | |
duped_topic.reload | |
assert_equal("c", duped_topic.title) | |
end | |
DeveloperSalary = Struct.new(:amount) | |
def test_dup_with_aggregate_of_same_name_as_attribute | |
developer_with_aggregate = Class.new(ActiveRecord::Base) do | |
self.table_name = "developers" | |
composed_of :salary, class_name: "BasicsTest::DeveloperSalary", mapping: [%w(salary amount)] | |
end | |
dev = developer_with_aggregate.find(1) | |
assert_kind_of DeveloperSalary, dev.salary | |
dup = nil | |
assert_nothing_raised { dup = dev.dup } | |
assert_kind_of DeveloperSalary, dup.salary | |
assert_equal dev.salary.amount, dup.salary.amount | |
assert_not_predicate dup, :persisted? | |
# test if the attributes have been duped | |
original_amount = dup.salary.amount | |
dev.salary.amount = 1 | |
assert_equal original_amount, dup.salary.amount | |
assert dup.save | |
assert_predicate dup, :persisted? | |
assert_not_equal dup.id, dev.id | |
end | |
def test_dup_does_not_copy_associations | |
author = authors(:david) | |
assert_not_equal [], author.posts | |
author_dup = author.dup | |
assert_equal [], author_dup.posts | |
end | |
def test_clone_preserves_subtype | |
clone = nil | |
assert_nothing_raised { clone = Company.find(3).clone } | |
assert_kind_of Client, clone | |
end | |
def test_clone_of_new_object_with_defaults | |
developer = Developer.new | |
assert_not_predicate developer, :name_changed? | |
assert_not_predicate developer, :salary_changed? | |
cloned_developer = developer.clone | |
assert_not_predicate cloned_developer, :name_changed? | |
assert_not_predicate cloned_developer, :salary_changed? | |
end | |
def test_clone_of_new_object_marks_attributes_as_dirty | |
developer = Developer.new name: "Bjorn", salary: 100000 | |
assert_predicate developer, :name_changed? | |
assert_predicate developer, :salary_changed? | |
cloned_developer = developer.clone | |
assert_predicate cloned_developer, :name_changed? | |
assert_predicate cloned_developer, :salary_changed? | |
end | |
def test_clone_of_new_object_marks_as_dirty_only_changed_attributes | |
developer = Developer.new name: "Bjorn" | |
assert developer.name_changed? # obviously | |
assert_not developer.salary_changed? # attribute has non-nil default value, so treated as not changed | |
cloned_developer = developer.clone | |
assert_predicate cloned_developer, :name_changed? | |
assert_not cloned_developer.salary_changed? # ... and cloned instance should behave same | |
end | |
def test_dup_of_saved_object_marks_attributes_as_dirty | |
developer = Developer.create! name: "Bjorn", salary: 100000 | |
assert_not_predicate developer, :name_changed? | |
assert_not_predicate developer, :salary_changed? | |
cloned_developer = developer.dup | |
assert cloned_developer.name_changed? # both attributes differ from defaults | |
assert_predicate cloned_developer, :salary_changed? | |
end | |
def test_dup_of_saved_object_marks_as_dirty_only_changed_attributes | |
developer = Developer.create! name: "Bjorn" | |
assert_not developer.name_changed? # both attributes of saved object should be treated as not changed | |
assert_not_predicate developer, :salary_changed? | |
cloned_developer = developer.dup | |
assert cloned_developer.name_changed? # ... but on cloned object should be | |
assert_not cloned_developer.salary_changed? # ... BUT salary has non-nil default which should be treated as not changed on cloned instance | |
end | |
def test_bignum | |
company = Company.find(1) | |
company.rating = 2147483648 | |
company.save | |
company = Company.find(1) | |
assert_equal 2147483648, company.rating | |
end | |
def test_bignum_pk | |
company = Company.create!(id: 2147483648, name: "foo") | |
assert_equal company, Company.find(company.id) | |
end | |
if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter, :SQLite3Adapter) | |
def test_default_char_types | |
default = Default.new | |
assert_equal "Y", default.char1 | |
assert_equal "a varchar field", default.char2 | |
# Mysql text type can't have default value | |
unless current_adapter?(:Mysql2Adapter) | |
assert_equal "a text field", default.char3 | |
end | |
end | |
def test_default_in_local_time | |
with_timezone_config default: :local do | |
default = Default.new | |
assert_equal Date.new(2004, 1, 1), default.fixed_date | |
assert_equal Time.local(2004, 1, 1, 0, 0, 0, 0), default.fixed_time | |
if current_adapter?(:PostgreSQLAdapter) | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time_with_time_zone | |
end | |
end | |
end | |
def test_default_in_utc | |
with_timezone_config default: :utc do | |
default = Default.new | |
assert_equal Date.new(2004, 1, 1), default.fixed_date | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time | |
if current_adapter?(:PostgreSQLAdapter) | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time_with_time_zone | |
end | |
end | |
end | |
def test_default_in_utc_with_time_zone | |
with_timezone_config default: :utc do | |
Time.use_zone "Central Time (US & Canada)" do | |
default = Default.new | |
assert_equal Date.new(2004, 1, 1), default.fixed_date | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time | |
if current_adapter?(:PostgreSQLAdapter) | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time_with_time_zone | |
end | |
end | |
end | |
end | |
unless in_memory_db? | |
def test_connection_in_local_time | |
with_timezone_config default: :utc do | |
new_config = ActiveRecord::Base.connection_db_config.configuration_hash.merge(default_timezone: "local") | |
ActiveRecord::Base.establish_connection(new_config) | |
Default.reset_column_information | |
default = Default.new | |
assert_equal Date.new(2004, 1, 1), default.fixed_date | |
assert_equal Time.local(2004, 1, 1, 0, 0, 0, 0), default.fixed_time | |
if current_adapter?(:PostgreSQLAdapter) | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time_with_time_zone | |
end | |
end | |
ensure | |
ActiveRecord::Base.establish_connection :arunit | |
Default.reset_column_information | |
end | |
def test_connection_in_utc_time | |
with_timezone_config default: :local do | |
new_config = ActiveRecord::Base.connection_db_config.configuration_hash.merge(default_timezone: "utc") | |
ActiveRecord::Base.establish_connection(new_config) | |
Default.reset_column_information | |
default = Default.new | |
assert_equal Date.new(2004, 1, 1), default.fixed_date | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time | |
if current_adapter?(:PostgreSQLAdapter) | |
assert_equal Time.utc(2004, 1, 1, 0, 0, 0, 0), default.fixed_time_with_time_zone | |
end | |
end | |
ensure | |
ActiveRecord::Base.establish_connection :arunit | |
Default.reset_column_information | |
end | |
end | |
end | |
def test_auto_id | |
auto = AutoId.new | |
auto.save | |
assert(auto.id > 0) | |
end | |
def test_sql_injection_via_find | |
assert_raise(ActiveRecord::RecordNotFound, ActiveRecord::StatementInvalid) do | |
Topic.find("123456 OR id > 0") | |
end | |
end | |
def test_column_name_properly_quoted | |
col_record = ColumnName.new | |
col_record.references = 40 | |
assert col_record.save | |
col_record.references = 41 | |
assert col_record.save | |
assert_not_nil c2 = ColumnName.find(col_record.id) | |
assert_equal(41, c2.references) | |
end | |
def test_quoting_arrays | |
replies = Reply.all.merge!(where: [ "id IN (?)", topics(:first).replies.collect(&:id) ]).to_a | |
assert_equal topics(:first).replies.size, replies.size | |
replies = Reply.all.merge!(where: [ "id IN (?)", [] ]).to_a | |
assert_equal 0, replies.size | |
end | |
def test_quote | |
author_name = "\\ \001 ' \n \\n \"" | |
topic = Topic.create("author_name" => author_name) | |
assert_equal author_name, Topic.find(topic.id).author_name | |
end | |
def test_toggle_attribute | |
assert_not_predicate topics(:first), :approved? | |
topics(:first).toggle!(:approved) | |
assert_predicate topics(:first), :approved? | |
topic = topics(:first) | |
topic.toggle(:approved) | |
assert_not_predicate topic, :approved? | |
topic.reload | |
assert_predicate topic, :approved? | |
end | |
def test_reload | |
t1 = Topic.find(1) | |
t2 = Topic.find(1) | |
t1.title = "something else" | |
t1.save | |
t2.reload | |
assert_equal t1.title, t2.title | |
end | |
def test_switching_between_table_name | |
k = Class.new(Joke) | |
assert_difference("GoodJoke.count") do | |
k.table_name = "cold_jokes" | |
k.create | |
k.table_name = "funny_jokes" | |
k.create | |
end | |
end | |
def test_clear_cache_when_setting_table_name | |
original_table_name = Joke.table_name | |
Joke.table_name = "funny_jokes" | |
before_columns = Joke.columns | |
before_seq = Joke.sequence_name | |
Joke.table_name = "cold_jokes" | |
after_columns = Joke.columns | |
after_seq = Joke.sequence_name | |
assert_not_equal before_columns, after_columns | |
assert_not_equal before_seq, after_seq unless before_seq.nil? && after_seq.nil? | |
ensure | |
Joke.table_name = original_table_name | |
end | |
def test_dont_clear_sequence_name_when_setting_explicitly | |
k = Class.new(Joke) | |
k.sequence_name = "black_jokes_seq" | |
k.table_name = "cold_jokes" | |
before_seq = k.sequence_name | |
k.table_name = "funny_jokes" | |
after_seq = k.sequence_name | |
assert_equal before_seq, after_seq unless before_seq.nil? && after_seq.nil? | |
end | |
def test_dont_clear_inheritance_column_when_setting_explicitly | |
k = Class.new(Joke) | |
k.inheritance_column = "my_type" | |
before_inherit = k.inheritance_column | |
k.reset_column_information | |
after_inherit = k.inheritance_column | |
assert_equal before_inherit, after_inherit unless before_inherit.blank? && after_inherit.blank? | |
end | |
def test_set_table_name_symbol_converted_to_string | |
k = Class.new(Joke) | |
k.table_name = :cold_jokes | |
assert_equal "cold_jokes", k.table_name | |
end | |
def test_quoted_table_name_after_set_table_name | |
klass = Class.new(ActiveRecord::Base) | |
klass.table_name = "foo" | |
assert_equal "foo", klass.table_name | |
assert_equal klass.connection.quote_table_name("foo"), klass.quoted_table_name | |
klass.table_name = "bar" | |
assert_equal "bar", klass.table_name | |
assert_equal klass.connection.quote_table_name("bar"), klass.quoted_table_name | |
end | |
def test_set_table_name_with_inheritance | |
k = Class.new(ActiveRecord::Base) | |
def k.name; "Foo"; end | |
def k.table_name; super + "ks"; end | |
assert_equal "foosks", k.table_name | |
end | |
def test_sequence_name_with_abstract_class | |
ak = Class.new(ActiveRecord::Base) | |
ak.abstract_class = true | |
k = Class.new(ak) | |
k.table_name = "projects" | |
orig_name = k.sequence_name | |
skip "sequences not supported by db" unless orig_name | |
assert_equal k.reset_sequence_name, orig_name | |
end | |
def test_count_with_join | |
res = Post.count_by_sql "SELECT COUNT(*) FROM posts LEFT JOIN comments ON posts.id=comments.post_id WHERE posts.#{QUOTED_TYPE} = 'Post'" | |
res2 = Post.where("posts.#{QUOTED_TYPE} = 'Post'").joins("LEFT JOIN comments ON posts.id=comments.post_id").count | |
assert_equal res, res2 | |
res4 = Post.count_by_sql "SELECT COUNT(p.id) FROM posts p, comments co WHERE p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id" | |
res5 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").count | |
assert_equal res4, res5 | |
res6 = Post.count_by_sql "SELECT COUNT(DISTINCT p.id) FROM posts p, comments co WHERE p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id" | |
res7 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").distinct.count | |
assert_equal res6, res7 | |
end | |
def test_no_limit_offset | |
assert_nothing_raised do | |
Developer.all.merge!(offset: 2).to_a | |
end | |
end | |
def test_last | |
assert_equal Developer.all.merge!(order: "id desc").first, Developer.last | |
end | |
def test_all | |
developers = Developer.all | |
assert_kind_of ActiveRecord::Relation, developers | |
assert_equal Developer.all, developers | |
end | |
def test_all_with_conditions | |
assert_equal Developer.all.merge!(order: "id desc").to_a, Developer.order("id desc").to_a | |
end | |
def test_find_ordered_last | |
last = Developer.order("developers.salary ASC").last | |
assert_equal last, Developer.order("developers.salary": "ASC").to_a.last | |
end | |
def test_find_reverse_ordered_last | |
last = Developer.order("developers.salary DESC").last | |
assert_equal last, Developer.order("developers.salary": "DESC").to_a.last | |
end | |
def test_find_multiple_ordered_last | |
last = Developer.order("developers.name, developers.salary DESC").last | |
assert_equal last, Developer.order(:"developers.name", "developers.salary": "DESC").to_a.last | |
end | |
def test_find_keeps_multiple_order_values | |
combined = Developer.order("developers.name, developers.salary").to_a | |
assert_equal combined, Developer.order(:"developers.name", :"developers.salary").to_a | |
end | |
def test_find_keeps_multiple_group_values | |
combined = Developer.merge(group: "developers.name, developers.salary, developers.id, developers.legacy_created_at, developers.legacy_updated_at, developers.legacy_created_on, developers.legacy_updated_on").to_a | |
assert_equal combined, Developer.merge(group: ["developers.name", "developers.salary", "developers.id", "developers.created_at", "developers.updated_at", "developers.created_on", "developers.updated_on"]).to_a | |
end | |
def test_find_symbol_ordered_last | |
last = Developer.all.merge!(order: :salary).last | |
assert_equal last, Developer.all.merge!(order: :salary).to_a.last | |
end | |
def test_abstract_class_table_name | |
assert_nil AbstractCompany.table_name | |
end | |
def test_find_on_abstract_base_class_doesnt_use_type_condition | |
old_class = LooseDescendant | |
Object.send :remove_const, :LooseDescendant | |
descendant = old_class.create! first_name: "bob" | |
assert_not_nil LoosePerson.find(descendant.id), "Should have found instance of LooseDescendant when finding abstract LoosePerson: #{descendant.inspect}" | |
ensure | |
unless Object.const_defined?(:LooseDescendant) | |
Object.const_set :LooseDescendant, old_class | |
end | |
end | |
def test_assert_queries | |
query = lambda { ActiveRecord::Base.connection.execute "select count(*) from developers" } | |
assert_queries(2) { 2.times { query.call } } | |
assert_queries 1, &query | |
assert_no_queries { assert true } | |
end | |
def test_benchmark_with_log_level | |
original_logger = ActiveRecord::Base.logger | |
log = StringIO.new | |
ActiveRecord::Base.logger = ActiveSupport::Logger.new(log) | |
ActiveRecord::Base.logger.level = Logger::WARN | |
ActiveRecord::Base.benchmark("Debug Topic Count", level: :debug) { Topic.count } | |
ActiveRecord::Base.benchmark("Warn Topic Count", level: :warn) { Topic.count } | |
ActiveRecord::Base.benchmark("Error Topic Count", level: :error) { Topic.count } | |
assert_no_match(/Debug Topic Count/, log.string) | |
assert_match(/Warn Topic Count/, log.string) | |
assert_match(/Error Topic Count/, log.string) | |
ensure | |
ActiveRecord::Base.logger = original_logger | |
end | |
def test_benchmark_with_use_silence | |
original_logger = ActiveRecord::Base.logger | |
log = StringIO.new | |
ActiveRecord::Base.logger = ActiveSupport::Logger.new(log) | |
ActiveRecord::Base.logger.level = Logger::DEBUG | |
ActiveRecord::Base.benchmark("Logging", level: :debug, silence: false) { ActiveRecord::Base.logger.debug "Quiet" } | |
assert_match(/Quiet/, log.string) | |
ensure | |
ActiveRecord::Base.logger = original_logger | |
end | |
def test_clear_cache! | |
# preheat cache | |
c1 = Post.connection.schema_cache.columns("posts") | |
assert_not_equal 0, Post.connection.schema_cache.size | |
ActiveRecord::Base.clear_cache! | |
assert_equal 0, Post.connection.schema_cache.size | |
c2 = Post.connection.schema_cache.columns("posts") | |
assert_not_equal 0, Post.connection.schema_cache.size | |
assert_equal c1, c2 | |
end | |
def test_before_remove_const_resets_the_current_scope | |
# Done this way because a class cannot be defined in a method using the | |
# class keyword. | |
Object.const_set(:ReloadableModel, Class.new(ActiveRecord::Base)) | |
ReloadableModel.current_scope = ReloadableModel.all | |
assert_not_nil ActiveRecord::Scoping::ScopeRegistry.current_scope(ReloadableModel) # precondition | |
ReloadableModel.before_remove_const | |
assert_nil ActiveRecord::Scoping::ScopeRegistry.current_scope(ReloadableModel) | |
ensure | |
Object.send(:remove_const, :ReloadableModel) | |
end | |
def test_marshal_round_trip | |
expected = posts(:welcome) | |
marshalled = Marshal.dump(expected) | |
actual = Marshal.load(marshalled) | |
assert_equal expected.attributes, actual.attributes | |
end | |
def test_marshal_inspected_round_trip | |
expected = posts(:welcome) | |
expected.inspect | |
marshalled = Marshal.dump(expected) | |
actual = Marshal.load(marshalled) | |
assert_equal expected.attributes, actual.attributes | |
end | |
def test_marshal_new_record_round_trip | |
marshalled = Marshal.dump(Post.new) | |
post = Marshal.load(marshalled) | |
assert post.new_record?, "should be a new record" | |
end | |
def test_marshalling_with_associations | |
post = Post.new | |
post.comments.build | |
marshalled = Marshal.dump(post) | |
post = Marshal.load(marshalled) | |
assert_equal 1, post.comments.length | |
end | |
if Process.respond_to?(:fork) && !in_memory_db? | |
def test_marshal_between_processes | |
# Define a new model to ensure there are no caches | |
if self.class.const_defined?("Post", false) | |
flunk "there should be no post constant" | |
end | |
self.class.const_set("Post", Class.new(ActiveRecord::Base) { | |
has_many :comments | |
}) | |
rd, wr = IO.pipe | |
rd.binmode | |
wr.binmode | |
ActiveRecord::Base.connection_handler.clear_all_connections! | |
fork do | |
rd.close | |
post = Post.new | |
post.comments.build | |
wr.write Marshal.dump(post) | |
wr.close | |
end | |
wr.close | |
assert Marshal.load rd.read | |
rd.close | |
ensure | |
self.class.send(:remove_const, "Post") if self.class.const_defined?("Post", false) | |
end | |
end | |
def test_marshalling_new_record_round_trip_with_associations | |
post = Post.new | |
post.comments.build | |
post = Marshal.load(Marshal.dump(post)) | |
assert post.new_record?, "should be a new record" | |
end | |
def test_attribute_names | |
expected = ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description", "metadata"] | |
assert_equal expected, Company.attribute_names | |
end | |
def test_has_attribute | |
assert Company.has_attribute?("id") | |
assert Company.has_attribute?("type") | |
assert Company.has_attribute?("name") | |
assert Company.has_attribute?("new_name") | |
assert Company.has_attribute?("metadata") | |
assert_not Company.has_attribute?("lastname") | |
assert_not Company.has_attribute?("age") | |
company = Company.new | |
assert company.has_attribute?("id") | |
assert company.has_attribute?("type") | |
assert company.has_attribute?("name") | |
assert company.has_attribute?("new_name") | |
assert company.has_attribute?("metadata") | |
assert_not company.has_attribute?("lastname") | |
assert_not company.has_attribute?("age") | |
end | |
def test_has_attribute_with_symbol | |
assert Company.has_attribute?(:id) | |
assert Company.has_attribute?(:type) | |
assert Company.has_attribute?(:name) | |
assert Company.has_attribute?(:new_name) | |
assert Company.has_attribute?(:metadata) | |
assert_not Company.has_attribute?(:lastname) | |
assert_not Company.has_attribute?(:age) | |
company = Company.new | |
assert company.has_attribute?(:id) | |
assert company.has_attribute?(:type) | |
assert company.has_attribute?(:name) | |
assert company.has_attribute?(:new_name) | |
assert company.has_attribute?(:metadata) | |
assert_not company.has_attribute?(:lastname) | |
assert_not company.has_attribute?(:age) | |
end | |
def test_attribute_names_on_table_not_exists | |
assert_equal [], NonExistentTable.attribute_names | |
end | |
def test_attribute_names_on_abstract_class | |
assert_equal [], AbstractCompany.attribute_names | |
end | |
def test_touch_should_raise_error_on_a_new_object | |
company = Company.new(rating: 1, name: "37signals", firm_name: "37signals") | |
assert_raises(ActiveRecord::ActiveRecordError) do | |
company.touch :updated_at | |
end | |
end | |
def test_distinct_delegates_to_scoped | |
assert_equal Bird.all.distinct, Bird.distinct | |
end | |
def test_table_name_with_2_abstract_subclasses | |
assert_equal "photos", Photo.table_name | |
end | |
def test_column_types_typecast | |
topic = Topic.first | |
assert_not_equal "t.lo", topic.author_name | |
attrs = topic.attributes.dup | |
attrs.delete "id" | |
typecast = Class.new(ActiveRecord::Type::Value) { | |
def cast(value) | |
"t.lo" | |
end | |
} | |
types = { "author_name" => typecast.new } | |
topic = Topic.instantiate(attrs, types) | |
assert_equal "t.lo", topic.author_name | |
end | |
def test_typecasting_aliases | |
assert_equal 10, Topic.select("10 as tenderlove").first.tenderlove | |
end | |
def test_default_values_are_deeply_dupped | |
company = Company.new | |
company.description << "foo" | |
assert_equal "", Company.new.description | |
end | |
test "scoped can take a values hash" do | |
klass = Class.new(ActiveRecord::Base) | |
klass.table_name = "bar" | |
assert_equal ["foo"], klass.all.merge!(select: "foo").select_values | |
end | |
test "connection_handler can be overridden" do | |
klass = Class.new(ActiveRecord::Base) | |
orig_handler = klass.connection_handler | |
new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
thread_connection_handler = nil | |
t = Thread.new do | |
klass.connection_handler = new_handler | |
thread_connection_handler = klass.connection_handler | |
end | |
t.join | |
assert_equal klass.connection_handler, orig_handler | |
assert_equal thread_connection_handler, new_handler | |
end | |
test "new threads get default the default connection handler" do | |
klass = Class.new(ActiveRecord::Base) | |
orig_handler = klass.connection_handler | |
handler = nil | |
t = Thread.new do | |
handler = klass.connection_handler | |
end | |
t.join | |
assert_equal handler, orig_handler | |
assert_equal klass.connection_handler, orig_handler | |
assert_equal klass.default_connection_handler, orig_handler | |
end | |
test "changing a connection handler in a main thread does not poison the other threads" do | |
klass = Class.new(ActiveRecord::Base) | |
orig_handler = klass.connection_handler | |
new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new | |
after_handler = nil | |
latch1 = Concurrent::CountDownLatch.new | |
latch2 = Concurrent::CountDownLatch.new | |
t = Thread.new do | |
klass.connection_handler = new_handler | |
latch1.count_down | |
latch2.wait | |
after_handler = klass.connection_handler | |
end | |
latch1.wait | |
klass.connection_handler = orig_handler | |
latch2.count_down | |
t.join | |
assert_equal after_handler, new_handler | |
assert_equal orig_handler, klass.connection_handler | |
end | |
# Note: This is a performance optimization for Array#uniq and Hash#[] with | |
# AR::Base objects. If the future has made this irrelevant, feel free to | |
# delete this. | |
test "records without an id have unique hashes" do | |
assert_not_equal Post.new.hash, Post.new.hash | |
end | |
test "records of different classes have different hashes" do | |
assert_not_equal Post.new(id: 1).hash, Developer.new(id: 1).hash | |
end | |
test "resetting column information doesn't remove attribute methods" do | |
topic = topics(:first) | |
assert_not_predicate topic, :id_changed? | |
Topic.reset_column_information | |
assert_not_predicate topic, :id_changed? | |
end | |
test "ignored columns are not present in columns_hash" do | |
cache_columns = Developer.connection.schema_cache.columns_hash(Developer.table_name) | |
assert_includes cache_columns.keys, "first_name" | |
assert_not_includes Developer.columns_hash.keys, "first_name" | |
assert_not_includes SubDeveloper.columns_hash.keys, "first_name" | |
assert_not_includes SymbolIgnoredDeveloper.columns_hash.keys, "first_name" | |
end | |
test ".columns_hash raises an error if the record has an empty table name" do | |
expected_message = "FirstAbstractClass has no table configured. Set one with FirstAbstractClass.table_name=" | |
exception = assert_raises(ActiveRecord::TableNotSpecified) do | |
FirstAbstractClass.columns_hash | |
end | |
assert_equal expected_message, exception.message | |
end | |
test "ignored columns have no attribute methods" do | |
assert_not_respond_to Developer.new, :first_name | |
assert_not_respond_to Developer.new, :first_name= | |
assert_not_respond_to Developer.new, :first_name? | |
assert_not_respond_to SubDeveloper.new, :first_name | |
assert_not_respond_to SubDeveloper.new, :first_name= | |
assert_not_respond_to SubDeveloper.new, :first_name? | |
assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name | |
assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name= | |
assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name? | |
end | |
test "ignored columns don't prevent explicit declaration of attribute methods" do | |
assert_respond_to Developer.new, :last_name | |
assert_respond_to Developer.new, :last_name= | |
assert_respond_to Developer.new, :last_name? | |
assert_respond_to SubDeveloper.new, :last_name | |
assert_respond_to SubDeveloper.new, :last_name= | |
assert_respond_to SubDeveloper.new, :last_name? | |
assert_respond_to SymbolIgnoredDeveloper.new, :last_name | |
assert_respond_to SymbolIgnoredDeveloper.new, :last_name= | |
assert_respond_to SymbolIgnoredDeveloper.new, :last_name? | |
end | |
test "ignored columns are stored as an array of string" do | |
assert_equal(%w(first_name last_name), Developer.ignored_columns) | |
assert_equal(%w(first_name last_name), SymbolIgnoredDeveloper.ignored_columns) | |
end | |
test "when #reload called, ignored columns' attribute methods are not defined" do | |
developer = Developer.create!(name: "Developer") | |
assert_not_respond_to developer, :first_name | |
assert_not_respond_to developer, :first_name= | |
developer.reload | |
assert_not_respond_to developer, :first_name | |
assert_not_respond_to developer, :first_name= | |
end | |
test "when ignored attribute is loaded, cast type should be preferred over DB type" do | |
developer = AttributedDeveloper.create | |
developer.update_column :name, "name" | |
loaded_developer = AttributedDeveloper.where(id: developer.id).select("*").first | |
assert_equal "Developer: name", loaded_developer.name | |
end | |
test "when assigning new ignored columns it invalidates cache for column names" do | |
assert_not_includes ColumnNamesCachedDeveloper.column_names, "name" | |
end | |
test "ignored columns not included in SELECT" do | |
query = Developer.all.to_sql.downcase | |
# ignored column | |
assert_not query.include?("first_name") | |
# regular column | |
assert query.include?("name") | |
end | |
test "column names are quoted when using #from clause and model has ignored columns" do | |
assert_not_empty Developer.ignored_columns | |
query = Developer.from("developers").to_sql | |
quoted_id = "#{Developer.quoted_table_name}.#{Developer.quoted_primary_key}" | |
assert_match(/SELECT #{Regexp.escape(quoted_id)}.* FROM developers/, query) | |
end | |
test "using table name qualified column names unless having SELECT list explicitly" do | |
assert_equal developers(:david), Developer.from("developers").joins(:shared_computers).take | |
end | |
test "protected environments by default is an array with production" do | |
assert_equal ["production"], ActiveRecord::Base.protected_environments | |
end | |
def test_protected_environments_are_stored_as_an_array_of_string | |
previous_protected_environments = ActiveRecord::Base.protected_environments | |
ActiveRecord::Base.protected_environments = [:staging, "production"] | |
assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments | |
ensure | |
ActiveRecord::Base.protected_environments = previous_protected_environments | |
end | |
test "cannot call connects_to on non-abstract or non-ActiveRecord::Base classes" do | |
error = assert_raises(NotImplementedError) do | |
Bird.connects_to(database: { writing: :arunit }) | |
end | |
assert_equal "`connects_to` can only be called on ActiveRecord::Base or abstract classes", error.message | |
end | |
test "cannot call connected_to on subclasses of ActiveRecord::Base with legacy connection handling" do | |
old_value = ActiveRecord.legacy_connection_handling | |
ActiveRecord.legacy_connection_handling = true | |
error = assert_raises(NotImplementedError) do | |
Bird.connected_to(role: :reading) { } | |
end | |
assert_equal "`connected_to` can only be called on ActiveRecord::Base with legacy connection handling.", error.message | |
ensure | |
clean_up_legacy_connection_handlers | |
ActiveRecord.legacy_connection_handling = old_value | |
end | |
test "cannot call connected_to with role and shard on non-abstract classes" do | |
error = assert_raises(NotImplementedError) do | |
Bird.connected_to(role: :reading, shard: :default) { } | |
end | |
assert_equal "calling `connected_to` is only allowed on ActiveRecord::Base or abstract classes.", error.message | |
end | |
test "can call connected_to with role and shard on abstract classes" do | |
SecondAbstractClass.connected_to(role: :reading, shard: :default) do | |
assert SecondAbstractClass.connected_to?(role: :reading, shard: :default) | |
end | |
end | |
test "cannot call connected_to on the abstract class that did not establish the connection" do | |
error = assert_raises(NotImplementedError) do | |
ThirdAbstractClass.connected_to(role: :reading) { } | |
end | |
assert_equal "calling `connected_to` is only allowed on the abstract class that established the connection.", error.message | |
end | |
test "#connecting_to with role" do | |
SecondAbstractClass.connecting_to(role: :reading) | |
assert SecondAbstractClass.connected_to?(role: :reading) | |
assert SecondAbstractClass.current_preventing_writes | |
ensure | |
ActiveRecord::Base.connected_to_stack.pop | |
end | |
test "#connecting_to with role and shard" do | |
SecondAbstractClass.connecting_to(role: :reading, shard: :default) | |
assert SecondAbstractClass.connected_to?(role: :reading, shard: :default) | |
ensure | |
ActiveRecord::Base.connected_to_stack.pop | |
end | |
test "#connecting_to with prevent_writes" do | |
SecondAbstractClass.connecting_to(role: :writing, prevent_writes: true) | |
assert SecondAbstractClass.connected_to?(role: :writing) | |
assert SecondAbstractClass.current_preventing_writes | |
ensure | |
ActiveRecord::Base.connected_to_stack.pop | |
end | |
test "#connecting_to doesn't work with legacy connection handling" do | |
old_value = ActiveRecord.legacy_connection_handling | |
ActiveRecord.legacy_connection_handling = true | |
assert_raises NotImplementedError do | |
SecondAbstractClass.connecting_to(role: :writing, prevent_writes: true) | |
end | |
ensure | |
ActiveRecord.legacy_connection_handling = old_value | |
end | |
test "#connected_to_many doesn't work with legacy connection handling" do | |
old_value = ActiveRecord.legacy_connection_handling | |
ActiveRecord.legacy_connection_handling = true | |
assert_raises NotImplementedError do | |
ActiveRecord::Base.connected_to_many([SecondAbstractClass], role: :writing) | |
end | |
ensure | |
ActiveRecord.legacy_connection_handling = old_value | |
end | |
test "#connected_to_many cannot be called on anything but ActiveRecord::Base" do | |
assert_raises NotImplementedError do | |
SecondAbstractClass.connected_to_many([SecondAbstractClass], role: :writing) | |
end | |
end | |
test "#connected_to_many cannot be called with classes that include ActiveRecord::Base" do | |
assert_raises NotImplementedError do | |
ActiveRecord::Base.connected_to_many([ActiveRecord::Base], role: :writing) | |
end | |
end | |
test "#connected_to_many sets prevent_writes if role is reading" do | |
ActiveRecord::Base.connected_to_many([SecondAbstractClass], role: :reading) do | |
assert SecondAbstractClass.current_preventing_writes | |
assert_not ActiveRecord::Base.current_preventing_writes | |
end | |
end | |
test "#connected_to_many with a single argument for classes" do | |
ActiveRecord::Base.connected_to_many(SecondAbstractClass, role: :reading) do | |
assert SecondAbstractClass.current_preventing_writes | |
assert_not ActiveRecord::Base.current_preventing_writes | |
end | |
end | |
test "#connected_to_many with a multiple classes without brackets works" do | |
ActiveRecord::Base.connected_to_many(FirstAbstractClass, SecondAbstractClass, role: :reading) do | |
assert FirstAbstractClass.current_preventing_writes | |
assert SecondAbstractClass.current_preventing_writes | |
assert_not ActiveRecord::Base.current_preventing_writes | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
class PredicateBuilder | |
class BasicObjectHandler # :nodoc: | |
def initialize(predicate_builder) | |
@predicate_builder = predicate_builder | |
end | |
def call(attribute, value) | |
bind = predicate_builder.build_bind_attribute(attribute.name, value) | |
attribute.eq(bind) | |
end | |
private | |
attr_reader :predicate_builder | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Associations | |
class Preloader | |
class Batch # :nodoc: | |
def initialize(preloaders, available_records:) | |
@preloaders = preloaders.reject(&:empty?) | |
@available_records = available_records.flatten.group_by { |r| r.class.base_class } | |
end | |
def call | |
branches = @preloaders.flat_map(&:branches) | |
until branches.empty? | |
loaders = branches.flat_map(&:runnable_loaders) | |
loaders.each { |loader| loader.associate_records_from_unscoped(@available_records[loader.klass.base_class]) } | |
if loaders.any? | |
future_tables = branches.flat_map do |branch| | |
branch.future_classes - branch.runnable_loaders.map(&:klass) | |
end.map(&:table_name).uniq | |
target_loaders = loaders.reject { |l| future_tables.include?(l.table_name) } | |
target_loaders = loaders if target_loaders.empty? | |
group_and_load_similar(target_loaders) | |
target_loaders.each(&:run) | |
end | |
finished, in_progress = branches.partition(&:done?) | |
branches = in_progress + finished.flat_map(&:children) | |
end | |
end | |
private | |
attr_reader :loaders | |
def group_and_load_similar(loaders) | |
loaders.grep_v(ThroughAssociation).group_by(&:loader_query).each_pair do |query, similar_loaders| | |
query.load_records_in_batch(similar_loaders) | |
end | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Batches | |
class BatchEnumerator | |
include Enumerable | |
def initialize(of: 1000, start: nil, finish: nil, relation:) # :nodoc: | |
@of = of | |
@relation = relation | |
@start = start | |
@finish = finish | |
end | |
# The primary key value from which the BatchEnumerator starts, inclusive of the value. | |
attr_reader :start | |
# The primary key value at which the BatchEnumerator ends, inclusive of the value. | |
attr_reader :finish | |
# The relation from which the BatchEnumerator yields batches. | |
attr_reader :relation | |
# The size of the batches yielded by the BatchEnumerator. | |
def batch_size | |
@of | |
end | |
# Looping through a collection of records from the database (using the | |
# +all+ method, for example) is very inefficient since it will try to | |
# instantiate all the objects at once. | |
# | |
# In that case, batch processing methods allow you to work with the | |
# records in batches, thereby greatly reducing memory consumption. | |
# | |
# Person.in_batches.each_record do |person| | |
# person.do_awesome_stuff | |
# end | |
# | |
# Person.where("age > 21").in_batches(of: 10).each_record do |person| | |
# person.party_all_night! | |
# end | |
# | |
# If you do not provide a block to #each_record, it will return an Enumerator | |
# for chaining with other methods: | |
# | |
# Person.in_batches.each_record.with_index do |person, index| | |
# person.award_trophy(index + 1) | |
# end | |
def each_record(&block) | |
return to_enum(:each_record) unless block_given? | |
@relation.to_enum(:in_batches, of: @of, start: @start, finish: @finish, load: true).each do |relation| | |
relation.records.each(&block) | |
end | |
end | |
# Deletes records in batches. Returns the total number of rows affected. | |
# | |
# Person.in_batches.delete_all | |
# | |
# See Relation#delete_all for details of how each batch is deleted. | |
def delete_all | |
sum(&:delete_all) | |
end | |
# Updates records in batches. Returns the total number of rows affected. | |
# | |
# Person.in_batches.update_all("age = age + 1") | |
# | |
# See Relation#update_all for details of how each batch is updated. | |
def update_all(updates) | |
sum do |relation| | |
relation.update_all(updates) | |
end | |
end | |
# Destroys records in batches. | |
# | |
# Person.where("age < 10").in_batches.destroy_all | |
# | |
# See Relation#destroy_all for details of how each batch is destroyed. | |
def destroy_all | |
each(&:destroy_all) | |
end | |
# Yields an ActiveRecord::Relation object for each batch of records. | |
# | |
# Person.in_batches.each do |relation| | |
# relation.update_all(awesome: true) | |
# end | |
def each(&block) | |
enum = @relation.to_enum(:in_batches, of: @of, start: @start, finish: @finish, load: false) | |
return enum.each(&block) if block_given? | |
enum | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "active_record/relation/batches/batch_enumerator" | |
module ActiveRecord | |
module Batches | |
ORDER_IGNORE_MESSAGE = "Scoped order is ignored, it's forced to be batch order." | |
# Looping through a collection of records from the database | |
# (using the Scoping::Named::ClassMethods.all method, for example) | |
# is very inefficient since it will try to instantiate all the objects at once. | |
# | |
# In that case, batch processing methods allow you to work | |
# with the records in batches, thereby greatly reducing memory consumption. | |
# | |
# The #find_each method uses #find_in_batches with a batch size of 1000 (or as | |
# specified by the +:batch_size+ option). | |
# | |
# Person.find_each do |person| | |
# person.do_awesome_stuff | |
# end | |
# | |
# Person.where("age > 21").find_each do |person| | |
# person.party_all_night! | |
# end | |
# | |
# If you do not provide a block to #find_each, it will return an Enumerator | |
# for chaining with other methods: | |
# | |
# Person.find_each.with_index do |person, index| | |
# person.award_trophy(index + 1) | |
# end | |
# | |
# ==== Options | |
# * <tt>:batch_size</tt> - Specifies the size of the batch. Defaults to 1000. | |
# * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value. | |
# * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value. | |
# * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when | |
# an order is present in the relation. | |
# * <tt>:order</tt> - Specifies the primary key order (can be +:asc+ or +:desc+). Defaults to +:asc+. | |
# | |
# Limits are honored, and if present there is no requirement for the batch | |
# size: it can be less than, equal to, or greater than the limit. | |
# | |
# The options +start+ and +finish+ are especially useful if you want | |
# multiple workers dealing with the same processing queue. You can make | |
# worker 1 handle all the records between id 1 and 9999 and worker 2 | |
# handle from 10000 and beyond by setting the +:start+ and +:finish+ | |
# option on each worker. | |
# | |
# # In worker 1, let's process until 9999 records. | |
# Person.find_each(finish: 9_999) do |person| | |
# person.party_all_night! | |
# end | |
# | |
# # In worker 2, let's process from record 10_000 and onwards. | |
# Person.find_each(start: 10_000) do |person| | |
# person.party_all_night! | |
# end | |
# | |
# NOTE: Order can be ascending (:asc) or descending (:desc). It is automatically set to | |
# ascending on the primary key ("id ASC"). | |
# This also means that this method only works when the primary key is | |
# orderable (e.g. an integer or string). | |
# | |
# NOTE: By its nature, batch processing is subject to race conditions if | |
# other processes are modifying the database. | |
def find_each(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc, &block) | |
if block_given? | |
find_in_batches(start: start, finish: finish, batch_size: batch_size, error_on_ignore: error_on_ignore, order: order) do |records| | |
records.each(&block) | |
end | |
else | |
enum_for(:find_each, start: start, finish: finish, batch_size: batch_size, error_on_ignore: error_on_ignore, order: order) do | |
relation = self | |
apply_limits(relation, start, finish, order).size | |
end | |
end | |
end | |
# Yields each batch of records that was found by the find options as | |
# an array. | |
# | |
# Person.where("age > 21").find_in_batches do |group| | |
# sleep(50) # Make sure it doesn't get too crowded in there! | |
# group.each { |person| person.party_all_night! } | |
# end | |
# | |
# If you do not provide a block to #find_in_batches, it will return an Enumerator | |
# for chaining with other methods: | |
# | |
# Person.find_in_batches.with_index do |group, batch| | |
# puts "Processing group ##{batch}" | |
# group.each(&:recover_from_last_night!) | |
# end | |
# | |
# To be yielded each record one by one, use #find_each instead. | |
# | |
# ==== Options | |
# * <tt>:batch_size</tt> - Specifies the size of the batch. Defaults to 1000. | |
# * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value. | |
# * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value. | |
# * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when | |
# an order is present in the relation. | |
# * <tt>:order</tt> - Specifies the primary key order (can be +:asc+ or +:desc+). Defaults to +:asc+. | |
# | |
# Limits are honored, and if present there is no requirement for the batch | |
# size: it can be less than, equal to, or greater than the limit. | |
# | |
# The options +start+ and +finish+ are especially useful if you want | |
# multiple workers dealing with the same processing queue. You can make | |
# worker 1 handle all the records between id 1 and 9999 and worker 2 | |
# handle from 10000 and beyond by setting the +:start+ and +:finish+ | |
# option on each worker. | |
# | |
# # Let's process from record 10_000 on. | |
# Person.find_in_batches(start: 10_000) do |group| | |
# group.each { |person| person.party_all_night! } | |
# end | |
# | |
# NOTE: Order can be ascending (:asc) or descending (:desc). It is automatically set to | |
# ascending on the primary key ("id ASC"). | |
# This also means that this method only works when the primary key is | |
# orderable (e.g. an integer or string). | |
# | |
# NOTE: By its nature, batch processing is subject to race conditions if | |
# other processes are modifying the database. | |
def find_in_batches(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc) | |
relation = self | |
unless block_given? | |
return to_enum(:find_in_batches, start: start, finish: finish, batch_size: batch_size, error_on_ignore: error_on_ignore, order: order) do | |
total = apply_limits(relation, start, finish, order).size | |
(total - 1).div(batch_size) + 1 | |
end | |
end | |
in_batches(of: batch_size, start: start, finish: finish, load: true, error_on_ignore: error_on_ignore, order: order) do |batch| | |
yield batch.to_a | |
end | |
end | |
# Yields ActiveRecord::Relation objects to work with a batch of records. | |
# | |
# Person.where("age > 21").in_batches do |relation| | |
# relation.delete_all | |
# sleep(10) # Throttle the delete queries | |
# end | |
# | |
# If you do not provide a block to #in_batches, it will return a | |
# BatchEnumerator which is enumerable. | |
# | |
# Person.in_batches.each_with_index do |relation, batch_index| | |
# puts "Processing relation ##{batch_index}" | |
# relation.delete_all | |
# end | |
# | |
# Examples of calling methods on the returned BatchEnumerator object: | |
# | |
# Person.in_batches.delete_all | |
# Person.in_batches.update_all(awesome: true) | |
# Person.in_batches.each_record(&:party_all_night!) | |
# | |
# ==== Options | |
# * <tt>:of</tt> - Specifies the size of the batch. Defaults to 1000. | |
# * <tt>:load</tt> - Specifies if the relation should be loaded. Defaults to false. | |
# * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value. | |
# * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value. | |
# * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when | |
# an order is present in the relation. | |
# * <tt>:order</tt> - Specifies the primary key order (can be +:asc+ or +:desc+). Defaults to +:asc+. | |
# | |
# Limits are honored, and if present there is no requirement for the batch | |
# size, it can be less than, equal, or greater than the limit. | |
# | |
# The options +start+ and +finish+ are especially useful if you want | |
# multiple workers dealing with the same processing queue. You can make | |
# worker 1 handle all the records between id 1 and 9999 and worker 2 | |
# handle from 10000 and beyond by setting the +:start+ and +:finish+ | |
# option on each worker. | |
# | |
# # Let's process from record 10_000 on. | |
# Person.in_batches(start: 10_000).update_all(awesome: true) | |
# | |
# An example of calling where query method on the relation: | |
# | |
# Person.in_batches.each do |relation| | |
# relation.update_all('age = age + 1') | |
# relation.where('age > 21').update_all(should_party: true) | |
# relation.where('age <= 21').delete_all | |
# end | |
# | |
# NOTE: If you are going to iterate through each record, you should call | |
# #each_record on the yielded BatchEnumerator: | |
# | |
# Person.in_batches.each_record(&:party_all_night!) | |
# | |
# NOTE: Order can be ascending (:asc) or descending (:desc). It is automatically set to | |
# ascending on the primary key ("id ASC"). | |
# This also means that this method only works when the primary key is | |
# orderable (e.g. an integer or string). | |
# | |
# NOTE: By its nature, batch processing is subject to race conditions if | |
# other processes are modifying the database. | |
def in_batches(of: 1000, start: nil, finish: nil, load: false, error_on_ignore: nil, order: :asc) | |
relation = self | |
unless block_given? | |
return BatchEnumerator.new(of: of, start: start, finish: finish, relation: self) | |
end | |
unless [:asc, :desc].include?(order) | |
raise ArgumentError, ":order must be :asc or :desc, got #{order.inspect}" | |
end | |
if arel.orders.present? | |
act_on_ignored_order(error_on_ignore) | |
end | |
batch_limit = of | |
if limit_value | |
remaining = limit_value | |
batch_limit = remaining if remaining < batch_limit | |
end | |
relation = relation.reorder(batch_order(order)).limit(batch_limit) | |
relation = apply_limits(relation, start, finish, order) | |
relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching | |
batch_relation = relation | |
loop do | |
if load | |
records = batch_relation.records | |
ids = records.map(&:id) | |
yielded_relation = where(primary_key => ids) | |
yielded_relation.load_records(records) | |
else | |
ids = batch_relation.pluck(primary_key) | |
yielded_relation = where(primary_key => ids) | |
end | |
break if ids.empty? | |
primary_key_offset = ids.last | |
raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset | |
yield yielded_relation | |
break if ids.length < batch_limit | |
if limit_value | |
remaining -= ids.length | |
if remaining == 0 | |
# Saves a useless iteration when the limit is a multiple of the | |
# batch size. | |
break | |
elsif remaining < batch_limit | |
relation = relation.limit(remaining) | |
end | |
end | |
batch_relation = relation.where( | |
predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt] | |
) | |
end | |
end | |
private | |
def apply_limits(relation, start, finish, order) | |
relation = apply_start_limit(relation, start, order) if start | |
relation = apply_finish_limit(relation, finish, order) if finish | |
relation | |
end | |
def apply_start_limit(relation, start, order) | |
relation.where(predicate_builder[primary_key, start, order == :desc ? :lteq : :gteq]) | |
end | |
def apply_finish_limit(relation, finish, order) | |
relation.where(predicate_builder[primary_key, finish, order == :desc ? :gteq : :lteq]) | |
end | |
def batch_order(order) | |
table[primary_key].public_send(order) | |
end | |
def act_on_ignored_order(error_on_ignore) | |
raise_error = (error_on_ignore.nil? ? ActiveRecord.error_on_ignored_order : error_on_ignore) | |
if raise_error | |
raise ArgumentError.new(ORDER_IGNORE_MESSAGE) | |
elsif logger | |
logger.warn(ORDER_IGNORE_MESSAGE) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/comment" | |
require "models/post" | |
require "models/subscriber" | |
class EachTest < ActiveRecord::TestCase | |
fixtures :posts, :subscribers | |
def setup | |
@posts = Post.order("id asc") | |
@total = Post.count | |
Post.count("id") # preheat arel's table cache | |
end | |
def test_each_should_execute_one_query_per_batch | |
assert_queries(@total + 1) do | |
Post.find_each(batch_size: 1) do |post| | |
assert_kind_of Post, post | |
end | |
end | |
end | |
def test_each_should_not_return_query_chain_and_execute_only_one_query | |
assert_queries(1) do | |
result = Post.find_each(batch_size: 100000) { } | |
assert_nil result | |
end | |
end | |
def test_each_should_return_an_enumerator_if_no_block_is_present | |
assert_queries(1) do | |
Post.find_each(batch_size: 100000).with_index do |post, index| | |
assert_kind_of Post, post | |
assert_kind_of Integer, index | |
end | |
end | |
end | |
def test_each_should_return_a_sized_enumerator | |
assert_equal 11, Post.find_each(batch_size: 1).size | |
assert_equal 5, Post.find_each(batch_size: 2, start: 7).size | |
assert_equal 11, Post.find_each(batch_size: 10_000).size | |
end | |
def test_each_enumerator_should_execute_one_query_per_batch | |
assert_queries(@total + 1) do | |
Post.find_each(batch_size: 1).with_index do |post, index| | |
assert_kind_of Post, post | |
assert_kind_of Integer, index | |
end | |
end | |
end | |
def test_each_should_raise_if_select_is_set_without_id | |
assert_raise(ArgumentError) do | |
Post.select(:title).find_each(batch_size: 1) { |post| | |
flunk "should not call this block" | |
} | |
end | |
end | |
def test_each_should_execute_if_id_is_in_select | |
assert_queries(6) do | |
Post.select("id, title, type").find_each(batch_size: 2) do |post| | |
assert_kind_of Post, post | |
end | |
end | |
end | |
test "find_each should honor limit if passed a block" do | |
limit = @total - 1 | |
total = 0 | |
Post.limit(limit).find_each do |post| | |
total += 1 | |
end | |
assert_equal limit, total | |
end | |
test "find_each should honor limit if no block is passed" do | |
limit = @total - 1 | |
total = 0 | |
Post.limit(limit).find_each.each do |post| | |
total += 1 | |
end | |
assert_equal limit, total | |
end | |
def test_warn_if_order_scope_is_set | |
assert_called(ActiveRecord::Base.logger, :warn) do | |
Post.order("title").find_each { |post| post } | |
end | |
end | |
def test_logger_not_required | |
previous_logger = ActiveRecord::Base.logger | |
ActiveRecord::Base.logger = nil | |
assert_nothing_raised do | |
Post.order("comments_count DESC").find_each { |post| post } | |
end | |
ensure | |
ActiveRecord::Base.logger = previous_logger | |
end | |
def test_find_in_batches_should_return_batches | |
assert_queries(@total + 1) do | |
Post.find_in_batches(batch_size: 1) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
end | |
end | |
end | |
def test_find_in_batches_should_start_from_the_start_option | |
assert_queries(@total) do | |
Post.find_in_batches(batch_size: 1, start: 2) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
end | |
end | |
end | |
def test_find_in_batches_should_end_at_the_finish_option | |
assert_queries(6) do | |
Post.find_in_batches(batch_size: 1, finish: 5) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
end | |
end | |
end | |
def test_find_in_batches_shouldnt_execute_query_unless_needed | |
assert_queries(2) do | |
Post.find_in_batches(batch_size: @total) { |batch| assert_kind_of Array, batch } | |
end | |
assert_queries(1) do | |
Post.find_in_batches(batch_size: @total + 1) { |batch| assert_kind_of Array, batch } | |
end | |
end | |
def test_find_in_batches_should_quote_batch_order | |
c = Post.connection | |
assert_sql(/ORDER BY #{Regexp.escape(c.quote_table_name("posts.id"))}/i) do | |
Post.find_in_batches(batch_size: 1) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
end | |
end | |
end | |
def test_find_in_batches_should_quote_batch_order_with_desc_order | |
c = Post.connection | |
assert_sql(/ORDER BY #{Regexp.escape(c.quote_table_name("posts.id"))} DESC/) do | |
Post.find_in_batches(batch_size: 1, order: :desc) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
end | |
end | |
end | |
def test_each_should_raise_if_order_is_invalid | |
assert_raise(ArgumentError) do | |
Post.select(:title).find_each(batch_size: 1, order: :invalid) { |post| | |
flunk "should not call this block" | |
} | |
end | |
end | |
def test_find_in_batches_should_not_use_records_after_yielding_them_in_case_original_array_is_modified | |
not_a_post = +"not a post" | |
def not_a_post.id; end | |
not_a_post.stub(:id, -> { raise StandardError.new("not_a_post had #id called on it") }) do | |
assert_nothing_raised do | |
Post.find_in_batches(batch_size: 1) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
batch.map! { not_a_post } | |
end | |
end | |
end | |
end | |
def test_find_in_batches_should_ignore_the_order_default_scope | |
# First post is with title scope | |
first_post = PostWithDefaultScope.first | |
posts = [] | |
PostWithDefaultScope.find_in_batches do |batch| | |
posts.concat(batch) | |
end | |
# posts.first will be ordered using id only. Title order scope should not apply here | |
assert_not_equal first_post, posts.first | |
assert_equal posts(:welcome).id, posts.first.id | |
end | |
def test_find_in_batches_should_error_on_ignore_the_order | |
assert_raise(ArgumentError) do | |
PostWithDefaultScope.find_in_batches(error_on_ignore: true) { } | |
end | |
end | |
def test_find_in_batches_should_not_error_if_config_overridden | |
# Set the config option which will be overridden | |
prev = ActiveRecord.error_on_ignored_order | |
ActiveRecord.error_on_ignored_order = true | |
assert_nothing_raised do | |
PostWithDefaultScope.find_in_batches(error_on_ignore: false) { } | |
end | |
ensure | |
# Set back to default | |
ActiveRecord.error_on_ignored_order = prev | |
end | |
def test_find_in_batches_should_error_on_config_specified_to_error | |
# Set the config option | |
prev = ActiveRecord.error_on_ignored_order | |
ActiveRecord.error_on_ignored_order = true | |
assert_raise(ArgumentError) do | |
PostWithDefaultScope.find_in_batches() { } | |
end | |
ensure | |
# Set back to default | |
ActiveRecord.error_on_ignored_order = prev | |
end | |
def test_find_in_batches_should_not_error_by_default | |
assert_nothing_raised do | |
PostWithDefaultScope.find_in_batches() { } | |
end | |
end | |
def test_find_in_batches_should_not_ignore_the_default_scope_if_it_is_other_then_order | |
default_scope = SpecialPostWithDefaultScope.all | |
posts = [] | |
SpecialPostWithDefaultScope.find_in_batches do |batch| | |
posts.concat(batch) | |
end | |
assert_equal default_scope.pluck(:id).sort, posts.map(&:id).sort | |
end | |
def test_find_in_batches_should_use_any_column_as_primary_key | |
nick_order_subscribers = Subscriber.order("nick asc") | |
start_nick = nick_order_subscribers.second.nick | |
subscribers = [] | |
Subscriber.find_in_batches(batch_size: 1, start: start_nick) do |batch| | |
subscribers.concat(batch) | |
end | |
assert_equal nick_order_subscribers[1..-1].map(&:id), subscribers.map(&:id) | |
end | |
def test_find_in_batches_should_use_any_column_as_primary_key_when_start_is_not_specified | |
assert_queries(Subscriber.count + 1) do | |
Subscriber.find_in_batches(batch_size: 1) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Subscriber, batch.first | |
end | |
end | |
end | |
def test_find_in_batches_should_return_an_enumerator | |
enum = nil | |
assert_no_queries do | |
enum = Post.find_in_batches(batch_size: 1) | |
end | |
assert_queries(4) do | |
enum.first(4) do |batch| | |
assert_kind_of Array, batch | |
assert_kind_of Post, batch.first | |
end | |
end | |
end | |
test "find_in_batches should honor limit if passed a block" do | |
limit = @total - 1 | |
total = 0 | |
Post.limit(limit).find_in_batches do |batch| | |
total += batch.size | |
end | |
assert_equal limit, total | |
end | |
test "find_in_batches should honor limit if no block is passed" do | |
limit = @total - 1 | |
total = 0 | |
Post.limit(limit).find_in_batches.each do |batch| | |
total += batch.size | |
end | |
assert_equal limit, total | |
end | |
def test_in_batches_should_not_execute_any_query | |
assert_no_queries do | |
assert_kind_of ActiveRecord::Batches::BatchEnumerator, Post.in_batches(of: 2) | |
end | |
end | |
def test_in_batches_has_attribute_readers | |
enumerator = Post.no_comments.in_batches(of: 2, start: 42, finish: 84) | |
assert_equal Post.no_comments, enumerator.relation | |
assert_equal 2, enumerator.batch_size | |
assert_equal 42, enumerator.start | |
assert_equal 84, enumerator.finish | |
end | |
def test_in_batches_should_yield_relation_if_block_given | |
assert_queries(6) do | |
Post.in_batches(of: 2) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
end | |
end | |
end | |
def test_in_batches_should_be_enumerable_if_no_block_given | |
assert_queries(6) do | |
Post.in_batches(of: 2).each do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
end | |
end | |
end | |
def test_in_batches_each_record_should_yield_record_if_block_is_given | |
assert_queries(6) do | |
Post.in_batches(of: 2).each_record do |post| | |
assert_predicate post.title, :present? | |
assert_kind_of Post, post | |
end | |
end | |
end | |
def test_in_batches_each_record_should_return_enumerator_if_no_block_given | |
assert_queries(6) do | |
Post.in_batches(of: 2).each_record.with_index do |post, i| | |
assert_predicate post.title, :present? | |
assert_kind_of Post, post | |
end | |
end | |
end | |
def test_in_batches_each_record_should_be_ordered_by_id | |
ids = Post.order("id ASC").pluck(:id) | |
assert_queries(6) do | |
Post.in_batches(of: 2).each_record.with_index do |post, i| | |
assert_equal ids[i], post.id | |
end | |
end | |
end | |
def test_in_batches_update_all_affect_all_records | |
assert_queries(6 + 6) do # 6 selects, 6 updates | |
Post.in_batches(of: 2).update_all(title: "updated-title") | |
end | |
assert_equal Post.all.pluck(:title), ["updated-title"] * Post.count | |
end | |
def test_in_batches_update_all_returns_rows_affected | |
assert_equal 11, Post.in_batches(of: 2).update_all(title: "updated-title") | |
end | |
def test_in_batches_update_all_returns_zero_when_no_batches | |
assert_equal 0, Post.where("1=0").in_batches(of: 2).update_all(title: "updated-title") | |
end | |
def test_in_batches_delete_all_should_not_delete_records_in_other_batches | |
not_deleted_count = Post.where("id <= 2").count | |
Post.where("id > 2").in_batches(of: 2).delete_all | |
assert_equal 0, Post.where("id > 2").count | |
assert_equal not_deleted_count, Post.count | |
end | |
def test_in_batches_delete_all_returns_rows_affected | |
assert_equal 11, Post.in_batches(of: 2).delete_all | |
end | |
def test_in_batches_delete_all_returns_zero_when_no_batches | |
assert_equal 0, Post.where("1=0").in_batches(of: 2).delete_all | |
end | |
def test_in_batches_should_not_be_loaded | |
Post.in_batches(of: 1) do |relation| | |
assert_not_predicate relation, :loaded? | |
end | |
Post.in_batches(of: 1, load: false) do |relation| | |
assert_not_predicate relation, :loaded? | |
end | |
end | |
def test_in_batches_should_be_loaded | |
Post.in_batches(of: 1, load: true) do |relation| | |
assert_predicate relation, :loaded? | |
end | |
end | |
def test_in_batches_if_not_loaded_executes_more_queries | |
assert_queries(@total + 1) do | |
Post.in_batches(of: 1, load: false) do |relation| | |
assert_not_predicate relation, :loaded? | |
end | |
end | |
end | |
def test_in_batches_should_return_relations | |
assert_queries(@total + 1) do | |
Post.in_batches(of: 1) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
end | |
end | |
end | |
def test_in_batches_should_start_from_the_start_option | |
post = Post.order("id ASC").where("id >= ?", 2).first | |
assert_queries(2) do | |
relation = Post.in_batches(of: 1, start: 2).first | |
assert_equal post, relation.first | |
end | |
end | |
def test_in_batches_should_end_at_the_finish_option | |
post = Post.order("id DESC").where("id <= ?", 5).first | |
assert_queries(7) do | |
relation = Post.in_batches(of: 1, finish: 5, load: true).reverse_each.first | |
assert_equal post, relation.last | |
end | |
end | |
def test_in_batches_shouldnt_execute_query_unless_needed | |
assert_queries(2) do | |
Post.in_batches(of: @total) { |relation| assert_kind_of ActiveRecord::Relation, relation } | |
end | |
assert_queries(1) do | |
Post.in_batches(of: @total + 1) { |relation| assert_kind_of ActiveRecord::Relation, relation } | |
end | |
end | |
def test_in_batches_should_quote_batch_order | |
c = Post.connection | |
assert_sql(/ORDER BY #{c.quote_table_name('posts')}\.#{c.quote_column_name('id')}/) do | |
Post.in_batches(of: 1) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
assert_kind_of Post, relation.first | |
end | |
end | |
end | |
def test_in_batches_should_quote_batch_order_with_desc_order | |
c = Post.connection | |
assert_sql(/ORDER BY #{Regexp.escape(c.quote_table_name("posts.id"))} DESC/) do | |
Post.in_batches(of: 1, order: :desc) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
assert_kind_of Post, relation.first | |
end | |
end | |
end | |
def test_in_batches_should_not_use_records_after_yielding_them_in_case_original_array_is_modified | |
not_a_post = +"not a post" | |
def not_a_post.id | |
raise StandardError.new("not_a_post had #id called on it") | |
end | |
assert_nothing_raised do | |
Post.in_batches(of: 1) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
assert_kind_of Post, relation.first | |
[not_a_post] * relation.count | |
end | |
end | |
end | |
def test_in_batches_should_not_ignore_default_scope_without_order_statements | |
default_scope = SpecialPostWithDefaultScope.all | |
posts = [] | |
SpecialPostWithDefaultScope.in_batches do |relation| | |
posts.concat(relation) | |
end | |
assert_equal default_scope.pluck(:id).sort, posts.map(&:id).sort | |
end | |
def test_in_batches_should_use_any_column_as_primary_key | |
nick_order_subscribers = Subscriber.order("nick asc") | |
start_nick = nick_order_subscribers.second.nick | |
subscribers = [] | |
Subscriber.in_batches(of: 1, start: start_nick) do |relation| | |
subscribers.concat(relation) | |
end | |
assert_equal nick_order_subscribers[1..-1].map(&:id), subscribers.map(&:id) | |
end | |
def test_in_batches_should_use_any_column_as_primary_key_when_start_is_not_specified | |
assert_queries(Subscriber.count + 1) do | |
Subscriber.in_batches(of: 1, load: true) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
assert_kind_of Subscriber, relation.first | |
end | |
end | |
end | |
def test_in_batches_should_return_an_enumerator | |
enum = nil | |
assert_no_queries do | |
enum = Post.in_batches(of: 1) | |
end | |
assert_queries(4) do | |
enum.first(4) do |relation| | |
assert_kind_of ActiveRecord::Relation, relation | |
assert_kind_of Post, relation.first | |
end | |
end | |
end | |
def test_in_batches_relations_should_not_overlap_with_each_other | |
seen_posts = [] | |
Post.in_batches(of: 2, load: true) do |relation| | |
relation.to_a.each do |post| | |
assert_not seen_posts.include?(post) | |
seen_posts << post | |
end | |
end | |
end | |
def test_in_batches_relations_with_condition_should_not_overlap_with_each_other | |
seen_posts = [] | |
author_id = Post.first.author_id | |
posts_by_author = Post.where(author_id: author_id) | |
Post.in_batches(of: 2) do |batch| | |
seen_posts += batch.where(author_id: author_id) | |
end | |
assert_equal posts_by_author.pluck(:id).sort, seen_posts.map(&:id).sort | |
end | |
def test_in_batches_relations_update_all_should_not_affect_matching_records_in_other_batches | |
Post.update_all(author_id: 0) | |
person = Post.last | |
person.update(author_id: 1) | |
Post.in_batches(of: 2) do |batch| | |
batch.where("author_id >= 1").update_all("author_id = author_id + 1") | |
end | |
assert_equal 2, person.reload.author_id # incremented only once | |
end | |
def test_find_in_batches_should_return_a_sized_enumerator | |
assert_equal 11, Post.find_in_batches(batch_size: 1).size | |
assert_equal 6, Post.find_in_batches(batch_size: 2).size | |
assert_equal 4, Post.find_in_batches(batch_size: 2, start: 4).size | |
assert_equal 4, Post.find_in_batches(batch_size: 3).size | |
assert_equal 1, Post.find_in_batches(batch_size: 10_000).size | |
end | |
[true, false].each do |load| | |
test "in_batches should return limit records when limit is less than batch size and load is #{load}" do | |
limit = 3 | |
batch_size = 5 | |
total = 0 | |
Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| | |
total += batch.count | |
end | |
assert_equal limit, total | |
end | |
test "in_batches should return limit records when limit is greater than batch size and load is #{load}" do | |
limit = 5 | |
batch_size = 3 | |
total = 0 | |
Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| | |
total += batch.count | |
end | |
assert_equal limit, total | |
end | |
test "in_batches should return limit records when limit is a multiple of the batch size and load is #{load}" do | |
limit = 6 | |
batch_size = 3 | |
total = 0 | |
Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| | |
total += batch.count | |
end | |
assert_equal limit, total | |
end | |
test "in_batches should return no records if the limit is 0 and load is #{load}" do | |
limit = 0 | |
batch_size = 1 | |
total = 0 | |
Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| | |
total += batch.count | |
end | |
assert_equal limit, total | |
end | |
test "in_batches should return all if the limit is greater than the number of records when load is #{load}" do | |
limit = @total + 1 | |
batch_size = 1 | |
total = 0 | |
Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| | |
total += batch.count | |
end | |
assert_equal @total, total | |
end | |
end | |
test ".find_each respects table alias" do | |
assert_queries(1) do | |
table_alias = Post.arel_table.alias("omg_posts") | |
table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias) | |
predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) | |
posts = ActiveRecord::Relation.create( | |
Post, | |
table: table_alias, | |
predicate_builder: predicate_builder | |
) | |
posts.find_each { } | |
end | |
end | |
test ".find_each bypasses the query cache for its own queries" do | |
Post.cache do | |
assert_queries(2) do | |
Post.find_each { } | |
Post.find_each { } | |
end | |
end | |
end | |
test ".find_each does not disable the query cache inside the given block" do | |
Post.cache do | |
Post.find_each(start: 1, finish: 1) do |post| | |
assert_queries(1) do | |
post.comments.count | |
post.comments.count | |
end | |
end | |
end | |
end | |
test ".find_in_batches bypasses the query cache for its own queries" do | |
Post.cache do | |
assert_queries(2) do | |
Post.find_in_batches { } | |
Post.find_in_batches { } | |
end | |
end | |
end | |
test ".find_in_batches does not disable the query cache inside the given block" do | |
Post.cache do | |
Post.find_in_batches(start: 1, finish: 1) do |batch| | |
assert_queries(1) do | |
batch.first.comments.count | |
batch.first.comments.count | |
end | |
end | |
end | |
end | |
test ".in_batches bypasses the query cache for its own queries" do | |
Post.cache do | |
assert_queries(2) do | |
Post.in_batches { } | |
Post.in_batches { } | |
end | |
end | |
end | |
test ".in_batches does not disable the query cache inside the given block" do | |
Post.cache do | |
Post.in_batches(start: 1, finish: 1) do |relation| | |
assert_queries(1) do | |
relation.count | |
relation.count | |
end | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module AttributeMethods | |
# = Active Record Attribute Methods Before Type Cast | |
# | |
# ActiveRecord::AttributeMethods::BeforeTypeCast provides a way to | |
# read the value of the attributes before typecasting and deserialization. | |
# | |
# class Task < ActiveRecord::Base | |
# end | |
# | |
# task = Task.new(id: '1', completed_on: '2012-10-21') | |
# task.id # => 1 | |
# task.completed_on # => Sun, 21 Oct 2012 | |
# | |
# task.attributes_before_type_cast | |
# # => {"id"=>"1", "completed_on"=>"2012-10-21", ... } | |
# task.read_attribute_before_type_cast('id') # => "1" | |
# task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" | |
# | |
# In addition to #read_attribute_before_type_cast and #attributes_before_type_cast, | |
# it declares a method for all attributes with the <tt>*_before_type_cast</tt> | |
# suffix. | |
# | |
# task.id_before_type_cast # => "1" | |
# task.completed_on_before_type_cast # => "2012-10-21" | |
module BeforeTypeCast | |
extend ActiveSupport::Concern | |
included do | |
attribute_method_suffix "_before_type_cast", "_for_database", parameters: false | |
attribute_method_suffix "_came_from_user?", parameters: false | |
end | |
# Returns the value of the attribute identified by +attr_name+ before | |
# typecasting and deserialization. | |
# | |
# class Task < ActiveRecord::Base | |
# end | |
# | |
# task = Task.new(id: '1', completed_on: '2012-10-21') | |
# task.read_attribute('id') # => 1 | |
# task.read_attribute_before_type_cast('id') # => '1' | |
# task.read_attribute('completed_on') # => Sun, 21 Oct 2012 | |
# task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" | |
# task.read_attribute_before_type_cast(:completed_on) # => "2012-10-21" | |
def read_attribute_before_type_cast(attr_name) | |
name = attr_name.to_s | |
name = self.class.attribute_aliases[name] || name | |
attribute_before_type_cast(name) | |
end | |
# Returns a hash of attributes before typecasting and deserialization. | |
# | |
# class Task < ActiveRecord::Base | |
# end | |
# | |
# task = Task.new(title: nil, is_done: true, completed_on: '2012-10-21') | |
# task.attributes | |
# # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>Sun, 21 Oct 2012, "created_at"=>nil, "updated_at"=>nil} | |
# task.attributes_before_type_cast | |
# # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>"2012-10-21", "created_at"=>nil, "updated_at"=>nil} | |
def attributes_before_type_cast | |
@attributes.values_before_type_cast | |
end | |
# Returns a hash of attributes for assignment to the database. | |
def attributes_for_database | |
@attributes.values_for_database | |
end | |
private | |
# Dispatch target for <tt>*_before_type_cast</tt> attribute methods. | |
def attribute_before_type_cast(attr_name) | |
@attributes[attr_name].value_before_type_cast | |
end | |
def attribute_for_database(attr_name) | |
@attributes[attr_name].value_for_database | |
end | |
def attribute_came_from_user?(attr_name) | |
@attributes[attr_name].came_from_user? | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
class String | |
# Enables more predictable duck-typing on String-like classes. See <tt>Object#acts_like?</tt>. | |
def acts_like_string? | |
true | |
end | |
end |
# frozen_string_literal: true | |
require_relative "behaviors/cache_delete_matched_behavior" | |
require_relative "behaviors/cache_increment_decrement_behavior" | |
require_relative "behaviors/cache_instrumentation_behavior" | |
require_relative "behaviors/cache_store_behavior" | |
require_relative "behaviors/cache_store_version_behavior" | |
require_relative "behaviors/cache_store_coder_behavior" | |
require_relative "behaviors/connection_pool_behavior" | |
require_relative "behaviors/encoded_key_cache_behavior" | |
require_relative "behaviors/failure_safety_behavior" | |
require_relative "behaviors/failure_raising_behavior" | |
require_relative "behaviors/local_cache_behavior" |
# frozen_string_literal: true | |
module ActiveRecord::Associations::Builder # :nodoc: | |
class BelongsTo < SingularAssociation # :nodoc: | |
def self.macro | |
:belongs_to | |
end | |
def self.valid_options(options) | |
valid = super + [:polymorphic, :counter_cache, :optional, :default] | |
valid += [:foreign_type] if options[:polymorphic] | |
valid += [:ensuring_owner_was] if options[:dependent] == :destroy_async | |
valid | |
end | |
def self.valid_dependent_options | |
[:destroy, :delete, :destroy_async] | |
end | |
def self.define_callbacks(model, reflection) | |
super | |
add_counter_cache_callbacks(model, reflection) if reflection.options[:counter_cache] | |
add_touch_callbacks(model, reflection) if reflection.options[:touch] | |
add_default_callbacks(model, reflection) if reflection.options[:default] | |
end | |
def self.add_counter_cache_callbacks(model, reflection) | |
cache_column = reflection.counter_cache_column | |
model.after_update lambda { |record| | |
association = association(reflection.name) | |
if association.saved_change_to_target? | |
association.increment_counters | |
association.decrement_counters_before_last_save | |
end | |
} | |
klass = reflection.class_name.safe_constantize | |
klass.attr_readonly cache_column if klass && klass.respond_to?(:attr_readonly) | |
end | |
def self.touch_record(o, changes, foreign_key, name, touch, touch_method) # :nodoc: | |
old_foreign_id = changes[foreign_key] && changes[foreign_key].first | |
if old_foreign_id | |
association = o.association(name) | |
reflection = association.reflection | |
if reflection.polymorphic? | |
foreign_type = reflection.foreign_type | |
klass = changes[foreign_type] && changes[foreign_type].first || o.public_send(foreign_type) | |
klass = o.class.polymorphic_class_for(klass) | |
else | |
klass = association.klass | |
end | |
primary_key = reflection.association_primary_key(klass) | |
old_record = klass.find_by(primary_key => old_foreign_id) | |
if old_record | |
if touch != true | |
old_record.public_send(touch_method, touch) | |
else | |
old_record.public_send(touch_method) | |
end | |
end | |
end | |
record = o.public_send name | |
if record && record.persisted? | |
if touch != true | |
record.public_send(touch_method, touch) | |
else | |
record.public_send(touch_method) | |
end | |
end | |
end | |
def self.add_touch_callbacks(model, reflection) | |
foreign_key = reflection.foreign_key | |
name = reflection.name | |
touch = reflection.options[:touch] | |
callback = lambda { |changes_method| lambda { |record| | |
BelongsTo.touch_record(record, record.send(changes_method), foreign_key, name, touch, belongs_to_touch_method) | |
}} | |
if reflection.counter_cache_column | |
touch_callback = callback.(:saved_changes) | |
update_callback = lambda { |record| | |
instance_exec(record, &touch_callback) unless association(reflection.name).saved_change_to_target? | |
} | |
model.after_update update_callback, if: :saved_changes? | |
else | |
model.after_create callback.(:saved_changes), if: :saved_changes? | |
model.after_update callback.(:saved_changes), if: :saved_changes? | |
model.after_destroy callback.(:changes_to_save) | |
end | |
model.after_touch callback.(:changes_to_save) | |
end | |
def self.add_default_callbacks(model, reflection) | |
model.before_validation lambda { |o| | |
o.association(reflection.name).default(&reflection.options[:default]) | |
} | |
end | |
def self.add_destroy_callbacks(model, reflection) | |
model.after_destroy lambda { |o| o.association(reflection.name).handle_dependency } | |
end | |
def self.define_validations(model, reflection) | |
if reflection.options.key?(:required) | |
reflection.options[:optional] = !reflection.options.delete(:required) | |
end | |
if reflection.options[:optional].nil? | |
required = model.belongs_to_required_by_default | |
else | |
required = !reflection.options[:optional] | |
end | |
super | |
if required | |
model.validates_presence_of reflection.name, message: :required | |
end | |
end | |
def self.define_change_tracking_methods(model, reflection) | |
model.generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1 | |
def #{reflection.name}_changed? | |
association(:#{reflection.name}).target_changed? | |
end | |
def #{reflection.name}_previously_changed? | |
association(:#{reflection.name}).target_previously_changed? | |
end | |
CODE | |
end | |
private_class_method :macro, :valid_options, :valid_dependent_options, :define_callbacks, | |
:define_validations, :define_change_tracking_methods, :add_counter_cache_callbacks, | |
:add_touch_callbacks, :add_default_callbacks, :add_destroy_callbacks | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Associations | |
# = Active Record Belongs To Association | |
class BelongsToAssociation < SingularAssociation # :nodoc: | |
def handle_dependency | |
return unless load_target | |
case options[:dependent] | |
when :destroy | |
raise ActiveRecord::Rollback unless target.destroy | |
when :destroy_async | |
id = owner.public_send(reflection.foreign_key.to_sym) | |
primary_key_column = reflection.active_record_primary_key.to_sym | |
enqueue_destroy_association( | |
owner_model_name: owner.class.to_s, | |
owner_id: owner.id, | |
association_class: reflection.klass.to_s, | |
association_ids: [id], | |
association_primary_key_column: primary_key_column, | |
ensuring_owner_was_method: options.fetch(:ensuring_owner_was, nil) | |
) | |
else | |
target.public_send(options[:dependent]) | |
end | |
end | |
def inversed_from(record) | |
replace_keys(record) | |
super | |
end | |
def default(&block) | |
writer(owner.instance_exec(&block)) if reader.nil? | |
end | |
def reset | |
super | |
@updated = false | |
end | |
def updated? | |
@updated | |
end | |
def decrement_counters | |
update_counters(-1) | |
end | |
def increment_counters | |
update_counters(1) | |
end | |
def decrement_counters_before_last_save | |
if reflection.polymorphic? | |
model_type_was = owner.attribute_before_last_save(reflection.foreign_type) | |
model_was = owner.class.polymorphic_class_for(model_type_was) if model_type_was | |
else | |
model_was = klass | |
end | |
foreign_key_was = owner.attribute_before_last_save(reflection.foreign_key) | |
if foreign_key_was && model_was < ActiveRecord::Base | |
update_counters_via_scope(model_was, foreign_key_was, -1) | |
end | |
end | |
def target_changed? | |
owner.attribute_changed?(reflection.foreign_key) || (!foreign_key_present? && target&.new_record?) | |
end | |
def target_previously_changed? | |
owner.attribute_previously_changed?(reflection.foreign_key) | |
end | |
def saved_change_to_target? | |
owner.saved_change_to_attribute?(reflection.foreign_key) | |
end | |
private | |
def replace(record) | |
if record | |
raise_on_type_mismatch!(record) | |
set_inverse_instance(record) | |
@updated = true | |
elsif target | |
remove_inverse_instance(target) | |
end | |
replace_keys(record, force: true) | |
self.target = record | |
end | |
def update_counters(by) | |
if require_counter_update? && foreign_key_present? | |
if target && !stale_target? | |
target.increment!(reflection.counter_cache_column, by, touch: reflection.options[:touch]) | |
else | |
update_counters_via_scope(klass, owner._read_attribute(reflection.foreign_key), by) | |
end | |
end | |
end | |
def update_counters_via_scope(klass, foreign_key, by) | |
scope = klass.unscoped.where!(primary_key(klass) => foreign_key) | |
scope.update_counters(reflection.counter_cache_column => by, touch: reflection.options[:touch]) | |
end | |
def find_target? | |
!loaded? && foreign_key_present? && klass | |
end | |
def require_counter_update? | |
reflection.counter_cache_column && owner.persisted? | |
end | |
def replace_keys(record, force: false) | |
target_key = record ? record._read_attribute(primary_key(record.class)) : nil | |
if force || owner._read_attribute(reflection.foreign_key) != target_key | |
owner[reflection.foreign_key] = target_key | |
end | |
end | |
def primary_key(klass) | |
reflection.association_primary_key(klass) | |
end | |
def foreign_key_present? | |
owner._read_attribute(reflection.foreign_key) | |
end | |
def invertible_for?(record) | |
inverse = inverse_reflection_for(record) | |
inverse && (inverse.has_one? || inverse.klass.has_many_inversing) | |
end | |
def stale_state | |
result = owner._read_attribute(reflection.foreign_key) { |n| owner.send(:missing_attribute, n, caller) } | |
result && result.to_s | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/developer" | |
require "models/project" | |
require "models/company" | |
require "models/topic" | |
require "models/reply" | |
require "models/computer" | |
require "models/post" | |
require "models/author" | |
require "models/tag" | |
require "models/tagging" | |
require "models/comment" | |
require "models/sponsor" | |
require "models/member" | |
require "models/essay" | |
require "models/toy" | |
require "models/invoice" | |
require "models/line_item" | |
require "models/column" | |
require "models/record" | |
require "models/admin" | |
require "models/admin/user" | |
require "models/ship" | |
require "models/treasure" | |
require "models/parrot" | |
require "models/book" | |
require "models/citation" | |
require "models/tree" | |
require "models/node" | |
require "models/club" | |
class BelongsToAssociationsTest < ActiveRecord::TestCase | |
fixtures :accounts, :companies, :developers, :projects, :topics, | |
:developers_projects, :computers, :authors, :author_addresses, | |
:essays, :posts, :tags, :taggings, :comments, :sponsors, :members, :nodes | |
def test_belongs_to | |
client = Client.find(3) | |
first_firm = companies(:first_firm) | |
assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do | |
assert_equal first_firm, client.firm | |
assert_equal first_firm.name, client.firm.name | |
end | |
end | |
def test_where_with_custom_primary_key | |
assert_equal [authors(:david)], Author.where(owned_essay: essays(:david_modest_proposal)) | |
end | |
def test_find_by_with_custom_primary_key | |
assert_equal authors(:david), Author.find_by(owned_essay: essays(:david_modest_proposal)) | |
end | |
def test_where_on_polymorphic_association_with_nil | |
assert_equal comments(:greetings), Comment.where(author: nil).first | |
assert_equal comments(:greetings), Comment.where(author: [nil]).first | |
end | |
def test_where_on_polymorphic_association_with_empty_array | |
assert_empty Comment.where(author: []) | |
end | |
def test_assigning_belongs_to_on_destroyed_object | |
client = Client.create!(name: "Client") | |
client.destroy! | |
assert_raise(FrozenError) { client.firm = nil } | |
assert_raise(FrozenError) { client.firm = Firm.new(name: "Firm") } | |
end | |
def test_eager_loading_wont_mutate_owner_record | |
client = Client.eager_load(:firm_with_basic_id).first | |
assert_not_predicate client, :firm_id_came_from_user? | |
client = Client.preload(:firm_with_basic_id).first | |
assert_not_predicate client, :firm_id_came_from_user? | |
end | |
def test_missing_attribute_error_is_raised_when_no_foreign_key_attribute | |
assert_raises(ActiveModel::MissingAttributeError) { Client.select(:id).first.firm } | |
end | |
def test_belongs_to_does_not_use_order_by | |
sql_log = capture_sql { Client.find(3).firm } | |
assert sql_log.all? { |sql| !/order by/i.match?(sql) }, "ORDER BY was used in the query: #{sql_log}" | |
end | |
def test_belongs_to_with_primary_key | |
client = Client.create(name: "Primary key client", firm_name: companies(:first_firm).name) | |
assert_equal companies(:first_firm).name, client.firm_with_primary_key.name | |
end | |
def test_belongs_to_with_primary_key_joins_on_correct_column | |
sql = Client.joins(:firm_with_primary_key).to_sql | |
if current_adapter?(:Mysql2Adapter) | |
assert_no_match(/`firm_with_primary_keys_companies`\.`id`/, sql) | |
assert_match(/`firm_with_primary_keys_companies`\.`name`/, sql) | |
elsif current_adapter?(:OracleAdapter) | |
# on Oracle aliases are truncated to 30 characters and are quoted in uppercase | |
assert_no_match(/"firm_with_primary_keys_compani"\."id"/i, sql) | |
assert_match(/"firm_with_primary_keys_compani"\."name"/i, sql) | |
else | |
assert_no_match(/"firm_with_primary_keys_companies"\."id"/, sql) | |
assert_match(/"firm_with_primary_keys_companies"\."name"/, sql) | |
end | |
end | |
def test_optional_relation_can_be_set_per_model | |
model1 = Class.new(ActiveRecord::Base) do | |
self.table_name = "accounts" | |
self.belongs_to_required_by_default = false | |
belongs_to :company | |
def self.name | |
"FirstModel" | |
end | |
end.new | |
model2 = Class.new(ActiveRecord::Base) do | |
self.table_name = "accounts" | |
self.belongs_to_required_by_default = true | |
belongs_to :company | |
def self.name | |
"SecondModel" | |
end | |
end.new | |
assert_predicate model1, :valid? | |
assert_not_predicate model2, :valid? | |
end | |
def test_optional_relation | |
original_value = ActiveRecord::Base.belongs_to_required_by_default | |
ActiveRecord::Base.belongs_to_required_by_default = true | |
model = Class.new(ActiveRecord::Base) do | |
self.table_name = "accounts" | |
def self.name; "Temp"; end | |
belongs_to :company, optional: true | |
end | |
account = model.new | |
assert_predicate account, :valid? | |
ensure | |
ActiveRecord::Base.belongs_to_required_by_default = original_value | |
end | |
def test_not_optional_relation | |
original_value = ActiveRecord::Base.belongs_to_required_by_default | |
ActiveRecord::Base.belongs_to_required_by_default = true | |
model = Class.new(ActiveRecord::Base) do | |
self.table_name = "accounts" | |
def self.name; "Temp"; end | |
belongs_to :company, optional: false | |
end | |
account = model.new | |
assert_not_predicate account, :valid? | |
assert_equal [{ error: :blank }], account.errors.details[:company] | |
ensure | |
ActiveRecord::Base.belongs_to_required_by_default = original_value | |
end | |
def test_required_belongs_to_config | |
original_value = ActiveRecord::Base.belongs_to_required_by_default | |
ActiveRecord::Base.belongs_to_required_by_default = true | |
model = Class.new(ActiveRecord::Base) do | |
self.table_name = "accounts" | |
def self.name; "Temp"; end | |
belongs_to :company | |
end | |
account = model.new | |
assert_not_predicate account, :valid? | |
assert_equal [{ error: :blank }], account.errors.details[:company] | |
ensure | |
ActiveRecord::Base.belongs_to_required_by_default = original_value | |
end | |
def test_default | |
david = developers(:david) | |
jamis = developers(:jamis) | |
model = Class.new(ActiveRecord::Base) do | |
self.table_name = "ships" | |
def self.name; "Temp"; end | |
belongs_to :developer, default: -> { david } | |
end | |
ship = model.create! | |
assert_equal david, ship.developer | |
ship = model.create!(developer: jamis) | |
assert_equal jamis, ship.developer | |
ship.update!(developer: nil) | |
assert_equal david, ship.developer | |
end | |
def test_default_with_lambda | |
model = Class.new(ActiveRecord::Base) do | |
self.table_name = "ships" | |
def self.name; "Temp"; end | |
belongs_to :developer, default: -> { default_developer } | |
def default_developer | |
Developer.first | |
end | |
end | |
ship = model.create! | |
assert_equal developers(:david), ship.developer | |
ship = model.create!(developer: developers(:jamis)) | |
assert_equal developers(:jamis), ship.developer | |
end | |
def test_default_scope_on_relations_is_not_cached | |
counter = 0 | |
comments = Class.new(ActiveRecord::Base) { | |
self.table_name = "comments" | |
self.inheritance_column = "not_there" | |
posts = Class.new(ActiveRecord::Base) { | |
self.table_name = "posts" | |
self.inheritance_column = "not_there" | |
default_scope -> { | |
counter += 1 | |
where("id = :inc", inc: counter) | |
} | |
has_many :comments, anonymous_class: comments | |
} | |
belongs_to :post, anonymous_class: posts, inverse_of: false | |
} | |
assert_equal 0, counter | |
comment = comments.first | |
assert_equal 0, counter | |
sql = capture_sql { comment.post } | |
comment.reload | |
assert_not_equal sql, capture_sql { comment.post } | |
end | |
def test_proxy_assignment | |
account = Account.find(1) | |
assert_nothing_raised { account.firm = account.firm } | |
end | |
def test_type_mismatch | |
assert_raise(ActiveRecord::AssociationTypeMismatch) { Account.find(1).firm = 1 } | |
assert_raise(ActiveRecord::AssociationTypeMismatch) { Account.find(1).firm = Project.find(1) } | |
end | |
def test_raises_type_mismatch_with_namespaced_class | |
assert_nil defined?(Region), "This test requires that there is no top-level Region class" | |
ActiveRecord::Base.connection.instance_eval do | |
create_table(:admin_regions, force: true) { |t| t.string :name } | |
add_column :admin_users, :region_id, :integer | |
end | |
Admin.const_set "RegionalUser", Class.new(Admin::User) { belongs_to(:region) } | |
Admin.const_set "Region", Class.new(ActiveRecord::Base) | |
e = assert_raise(ActiveRecord::AssociationTypeMismatch) { | |
Admin::RegionalUser.new(region: "wrong value") | |
} | |
assert_match(/^Region\([^)]+\) expected, got "wrong value" which is an instance of String\([^)]+\)$/, e.message) | |
ensure | |
Admin.send :remove_const, "Region" if Admin.const_defined?("Region") | |
Admin.send :remove_const, "RegionalUser" if Admin.const_defined?("RegionalUser") | |
ActiveRecord::Base.connection.instance_eval do | |
remove_column :admin_users, :region_id if column_exists?(:admin_users, :region_id) | |
drop_table :admin_regions, if_exists: true | |
end | |
Admin::User.reset_column_information | |
end | |
def test_natural_assignment | |
apple = Firm.create("name" => "Apple") | |
citibank = Account.create("credit_limit" => 10) | |
citibank.firm = apple | |
assert_equal apple.id, citibank.firm_id | |
end | |
def test_id_assignment | |
apple = Firm.create("name" => "Apple") | |
citibank = Account.create("credit_limit" => 10) | |
citibank.firm_id = apple | |
assert_nil citibank.firm_id | |
end | |
def test_natural_assignment_with_primary_key | |
apple = Firm.create("name" => "Apple") | |
citibank = Client.create("name" => "Primary key client") | |
citibank.firm_with_primary_key = apple | |
assert_equal apple.name, citibank.firm_name | |
end | |
def test_eager_loading_with_primary_key | |
Firm.create("name" => "Apple") | |
Client.create("name" => "Citibank", :firm_name => "Apple") | |
citibank_result = Client.all.merge!(where: { name: "Citibank" }, includes: :firm_with_primary_key).first | |
assert_predicate citibank_result.association(:firm_with_primary_key), :loaded? | |
end | |
def test_eager_loading_with_primary_key_as_symbol | |
Firm.create("name" => "Apple") | |
Client.create("name" => "Citibank", :firm_name => "Apple") | |
citibank_result = Client.all.merge!(where: { name: "Citibank" }, includes: :firm_with_primary_key_symbols).first | |
assert_predicate citibank_result.association(:firm_with_primary_key_symbols), :loaded? | |
end | |
def test_creating_the_belonging_object | |
citibank = Account.create("credit_limit" => 10) | |
apple = citibank.create_firm("name" => "Apple") | |
assert_equal apple, citibank.firm | |
citibank.save | |
citibank.reload | |
assert_equal apple, citibank.firm | |
end | |
def test_creating_the_belonging_object_from_new_record | |
citibank = Account.new("credit_limit" => 10) | |
apple = citibank.create_firm("name" => "Apple") | |
assert_equal apple, citibank.firm | |
citibank.save | |
citibank.reload | |
assert_equal apple, citibank.firm | |
end | |
def test_creating_the_belonging_object_with_primary_key | |
client = Client.create(name: "Primary key client") | |
apple = client.create_firm_with_primary_key("name" => "Apple") | |
assert_equal apple, client.firm_with_primary_key | |
client.save | |
client.reload | |
assert_equal apple, client.firm_with_primary_key | |
end | |
def test_building_the_belonging_object | |
citibank = Account.create("credit_limit" => 10) | |
apple = citibank.build_firm("name" => "Apple") | |
citibank.save | |
assert_equal apple.id, citibank.firm_id | |
end | |
def test_building_the_belonging_object_with_implicit_sti_base_class | |
account = Account.new | |
company = account.build_firm | |
assert_kind_of Company, company, "Expected #{company.class} to be a Company" | |
end | |
def test_building_the_belonging_object_with_explicit_sti_base_class | |
account = Account.new | |
company = account.build_firm(type: "Company") | |
assert_kind_of Company, company, "Expected #{company.class} to be a Company" | |
end | |
def test_building_the_belonging_object_with_sti_subclass | |
account = Account.new | |
company = account.build_firm(type: "Firm") | |
assert_kind_of Firm, company, "Expected #{company.class} to be a Firm" | |
end | |
def test_building_the_belonging_object_with_an_invalid_type | |
account = Account.new | |
assert_raise(ActiveRecord::SubclassNotFound) { account.build_firm(type: "InvalidType") } | |
end | |
def test_building_the_belonging_object_with_an_unrelated_type | |
account = Account.new | |
assert_raise(ActiveRecord::SubclassNotFound) { account.build_firm(type: "Account") } | |
end | |
def test_building_the_belonging_object_with_primary_key | |
client = Client.create(name: "Primary key client") | |
apple = client.build_firm_with_primary_key("name" => "Apple") | |
client.save | |
assert_equal apple.name, client.firm_name | |
end | |
def test_create! | |
client = Client.create!(name: "Jimmy") | |
account = client.create_account!(credit_limit: 10) | |
assert_equal account, client.account | |
assert_predicate account, :persisted? | |
client.save | |
client.reload | |
assert_equal account, client.account | |
end | |
def test_failing_create! | |
client = Client.create!(name: "Jimmy") | |
assert_raise(ActiveRecord::RecordInvalid) { client.create_account! } | |
assert_not_nil client.account | |
assert_predicate client.account, :new_record? | |
end | |
def test_reloading_the_belonging_object | |
odegy_account = accounts(:odegy_account) | |
assert_equal "Odegy", odegy_account.firm.name | |
Company.where(id: odegy_account.firm_id).update_all(name: "ODEGY") | |
assert_equal "Odegy", odegy_account.firm.name | |
assert_equal "ODEGY", odegy_account.reload_firm.name | |
end | |
def test_reload_the_belonging_object_with_query_cache | |
odegy_account_id = accounts(:odegy_account).id | |
connection = ActiveRecord::Base.connection | |
connection.enable_query_cache! | |
connection.clear_query_cache | |
# Populate the cache with a query | |
odegy_account = Account.find(odegy_account_id) | |
# Populate the cache with a second query | |
odegy_account.firm | |
assert_equal 2, connection.query_cache.size | |
# Clear the cache and fetch the firm again, populating the cache with a query | |
assert_queries(1) { odegy_account.reload_firm } | |
# This query is not cached anymore, so it should make a real SQL query | |
assert_queries(1) { Account.find(odegy_account_id) } | |
ensure | |
ActiveRecord::Base.connection.disable_query_cache! | |
end | |
def test_natural_assignment_to_nil | |
client = Client.find(3) | |
client.firm = nil | |
client.save | |
client.association(:firm).reload | |
assert_nil client.firm | |
assert_nil client.client_of | |
end | |
def test_natural_assignment_to_nil_with_primary_key | |
client = Client.create(name: "Primary key client", firm_name: companies(:first_firm).name) | |
client.firm_with_primary_key = nil | |
client.save | |
client.association(:firm_with_primary_key).reload | |
assert_nil client.firm_with_primary_key | |
assert_nil client.client_of | |
end | |
def test_with_different_class_name | |
assert_equal Company.find(1).name, Company.find(3).firm_with_other_name.name | |
assert_not_nil Company.find(3).firm_with_other_name, "Microsoft should have a firm" | |
end | |
def test_with_condition | |
assert_equal Company.find(1).name, Company.find(3).firm_with_condition.name | |
assert_not_nil Company.find(3).firm_with_condition, "Microsoft should have a firm" | |
end | |
def test_polymorphic_association_class | |
sponsor = Sponsor.new | |
assert_nil sponsor.association(:sponsorable).klass | |
sponsor.association(:sponsorable).reload | |
assert_nil sponsor.sponsorable | |
sponsor.sponsorable_type = "" # the column doesn't have to be declared NOT NULL | |
assert_nil sponsor.association(:sponsorable).klass | |
sponsor.association(:sponsorable).reload | |
assert_nil sponsor.sponsorable | |
sponsor.sponsorable = Member.new name: "Bert" | |
assert_equal Member, sponsor.association(:sponsorable).klass | |
end | |
def test_with_polymorphic_and_condition | |
sponsor = Sponsor.create | |
member = Member.create name: "Bert" | |
sponsor.sponsorable = member | |
sponsor.save! | |
assert_equal member, sponsor.sponsorable | |
assert_nil sponsor.sponsorable_with_conditions | |
sponsor = Sponsor.preload(:sponsorable, :sponsorable_with_conditions).last | |
assert_equal member, sponsor.sponsorable | |
assert_nil sponsor.sponsorable_with_conditions | |
end | |
def test_with_select | |
assert_equal 1, Post.find(2).author_with_select.attributes.size | |
assert_equal 1, Post.includes(:author_with_select).find(2).author_with_select.attributes.size | |
end | |
def test_custom_attribute_with_select | |
assert_equal 2, Company.find(2).firm_with_select.attributes.size | |
assert_equal 2, Company.includes(:firm_with_select).find(2).firm_with_select.attributes.size | |
end | |
def test_belongs_to_without_counter_cache_option | |
# Ship has a conventionally named `treasures_count` column, but the counter_cache | |
# option is not given on the association. | |
ship = Ship.create(name: "Countless") | |
assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed unless counter_cache is given on the relation" do | |
treasure = Treasure.new(name: "Gold", ship: ship) | |
treasure.save | |
end | |
assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed unless counter_cache is given on the relation" do | |
treasure = ship.treasures.first | |
treasure.destroy | |
end | |
end | |
def test_belongs_to_counter | |
debate = Topic.create("title" => "debate") | |
assert_equal 0, debate.read_attribute("replies_count"), "No replies yet" | |
trash = debate.replies.create("title" => "blah!", "content" => "world around!") | |
assert_equal 1, Topic.find(debate.id).read_attribute("replies_count"), "First reply created" | |
trash.destroy | |
assert_equal 0, Topic.find(debate.id).read_attribute("replies_count"), "First reply deleted" | |
end | |
def test_belongs_to_counter_with_assigning_nil | |
topic = Topic.create!(title: "debate") | |
reply = Reply.create!(title: "blah!", content: "world around!", topic: topic) | |
assert_equal topic.id, reply.parent_id | |
assert_equal 1, topic.reload.replies.size | |
reply.topic = nil | |
reply.reload | |
assert_equal topic.id, reply.parent_id | |
assert_equal 1, topic.reload.replies.size | |
reply.topic = nil | |
reply.save! | |
assert_equal 0, topic.reload.replies.size | |
end | |
def test_belongs_to_counter_with_assigning_new_object | |
topic = Topic.create!(title: "debate") | |
reply = Reply.create!(title: "blah!", content: "world around!", topic: topic) | |
assert_equal topic.id, reply.parent_id | |
assert_equal 1, topic.reload.replies_count | |
topic2 = reply.build_topic(title: "debate2") | |
reply.save! | |
assert_not_equal topic.id, reply.parent_id | |
assert_equal topic2.id, reply.parent_id | |
assert_equal 0, topic.reload.replies_count | |
assert_equal 1, topic2.reload.replies_count | |
end | |
def test_belongs_to_with_primary_key_counter | |
debate = Topic.create("title" => "debate") | |
debate2 = Topic.create("title" => "debate2") | |
reply = Reply.create("title" => "blah!", "content" => "world around!", "parent_title" => "debate2") | |
assert_equal 0, debate.reload.replies_count | |
assert_equal 1, debate2.reload.replies_count | |
reply.parent_title = "debate" | |
reply.save! | |
assert_equal 1, debate.reload.replies_count | |
assert_equal 0, debate2.reload.replies_count | |
assert_no_queries do | |
reply.topic_with_primary_key = debate | |
end | |
assert_equal 1, debate.reload.replies_count | |
assert_equal 0, debate2.reload.replies_count | |
reply.topic_with_primary_key = debate2 | |
reply.save! | |
assert_equal 0, debate.reload.replies_count | |
assert_equal 1, debate2.reload.replies_count | |
reply.topic_with_primary_key = nil | |
reply.save! | |
assert_equal 0, debate.reload.replies_count | |
assert_equal 0, debate2.reload.replies_count | |
end | |
def test_belongs_to_counter_with_reassigning | |
topic1 = Topic.create("title" => "t1") | |
topic2 = Topic.create("title" => "t2") | |
reply1 = Reply.new("title" => "r1", "content" => "r1") | |
reply1.topic = topic1 | |
assert reply1.save | |
assert_equal 1, Topic.find(topic1.id).replies.size | |
assert_equal 0, Topic.find(topic2.id).replies.size | |
reply1.topic = Topic.find(topic2.id) | |
assert_no_queries do | |
reply1.topic = topic2 | |
end | |
assert reply1.save | |
assert_equal 0, Topic.find(topic1.id).replies.size | |
assert_equal 1, Topic.find(topic2.id).replies.size | |
reply1.topic = nil | |
reply1.save! | |
assert_equal 0, Topic.find(topic1.id).replies.size | |
assert_equal 0, Topic.find(topic2.id).replies.size | |
reply1.topic = topic1 | |
reply1.save! | |
assert_equal 1, Topic.find(topic1.id).replies.size | |
assert_equal 0, Topic.find(topic2.id).replies.size | |
reply1.destroy | |
assert_equal 0, Topic.find(topic1.id).replies.size | |
assert_equal 0, Topic.find(topic2.id).replies.size | |
end | |
def test_belongs_to_reassign_with_namespaced_models_and_counters | |
topic1 = Web::Topic.create("title" => "t1") | |
topic2 = Web::Topic.create("title" => "t2") | |
reply1 = Web::Reply.new("title" => "r1", "content" => "r1") | |
reply1.topic = topic1 | |
assert reply1.save | |
assert_equal 1, Web::Topic.find(topic1.id).replies.size | |
assert_equal 0, Web::Topic.find(topic2.id).replies.size | |
reply1.topic = Web::Topic.find(topic2.id) | |
assert reply1.save | |
assert_equal 0, Web::Topic.find(topic1.id).replies.size | |
assert_equal 1, Web::Topic.find(topic2.id).replies.size | |
end | |
def test_belongs_to_counter_after_save | |
topic = Topic.create!(title: "monday night") | |
assert_queries(2) do | |
topic.replies.create!(title: "re: monday night", content: "football") | |
end | |
assert_equal 1, Topic.find(topic.id)[:replies_count] | |
topic.save! | |
assert_equal 1, Topic.find(topic.id)[:replies_count] | |
end | |
def test_belongs_to_counter_after_touch | |
topic = Topic.create!(title: "topic") | |
assert_equal 0, topic.replies_count | |
assert_equal 0, topic.after_touch_called | |
reply = Reply.create!(title: "blah!", content: "world around!", topic_with_primary_key: topic) | |
assert_equal 1, topic.replies_count | |
assert_equal 1, topic.after_touch_called | |
reply.destroy! | |
assert_equal 0, topic.replies_count | |
assert_equal 2, topic.after_touch_called | |
end | |
def test_belongs_to_touch_with_reassigning | |
debate = Topic.create!(title: "debate") | |
debate2 = Topic.create!(title: "debate2") | |
reply = Reply.create!(title: "blah!", content: "world around!", parent_title: "debate2") | |
time = 1.day.ago | |
debate.touch(time: time) | |
debate2.touch(time: time) | |
assert_queries(3) do | |
reply.parent_title = "debate" | |
reply.save! | |
end | |
assert_operator debate.reload.updated_at, :>, time | |
assert_operator debate2.reload.updated_at, :>, time | |
debate.touch(time: time) | |
debate2.touch(time: time) | |
assert_queries(3) do | |
reply.topic_with_primary_key = debate2 | |
reply.save! | |
end | |
assert_operator debate.reload.updated_at, :>, time | |
assert_operator debate2.reload.updated_at, :>, time | |
end | |
def test_belongs_to_with_touch_option_on_touch | |
line_item = LineItem.create! | |
Invoice.create!(line_items: [line_item]) | |
assert_queries(1) { line_item.touch } | |
end | |
def test_belongs_to_with_touch_on_multiple_records | |
line_item = LineItem.create!(amount: 1) | |
line_item2 = LineItem.create!(amount: 2) | |
Invoice.create!(line_items: [line_item, line_item2]) | |
assert_queries(1) do | |
LineItem.transaction do | |
line_item.touch | |
line_item2.touch | |
end | |
end | |
assert_queries(2) do | |
line_item.touch | |
line_item2.touch | |
end | |
end | |
def test_belongs_to_with_touch_option_on_touch_without_updated_at_attributes | |
assert_not LineItem.column_names.include?("updated_at") | |
line_item = LineItem.create! | |
invoice = Invoice.create!(line_items: [line_item]) | |
initial = invoice.updated_at | |
travel(1.second) do | |
line_item.touch | |
end | |
assert_not_equal initial, invoice.reload.updated_at | |
end | |
def test_belongs_to_with_touch_option_on_touch_and_removed_parent | |
line_item = LineItem.create! | |
Invoice.create!(line_items: [line_item]) | |
line_item.invoice = nil | |
assert_queries(2) { line_item.touch } | |
end | |
def test_belongs_to_with_touch_option_on_update | |
line_item = LineItem.create! | |
Invoice.create!(line_items: [line_item]) | |
assert_queries(2) { line_item.update amount: 10 } | |
end | |
def test_belongs_to_with_touch_option_on_empty_update | |
line_item = LineItem.create! | |
Invoice.create!(line_items: [line_item]) | |
assert_no_queries { line_item.save } | |
end | |
def test_belongs_to_with_touch_option_on_destroy | |
line_item = LineItem.create! | |
Invoice.create!(line_items: [line_item]) | |
assert_queries(2) { line_item.destroy } | |
end | |
def test_belongs_to_with_touch_option_on_destroy_with_destroyed_parent | |
line_item = LineItem.create! | |
invoice = Invoice.create!(line_items: [line_item]) | |
invoice.destroy | |
assert_queries(1) { line_item.destroy } | |
end | |
def test_belongs_to_with_touch_option_on_touch_and_reassigned_parent | |
line_item = LineItem.create! | |
Invoice.create!(line_items: [line_item]) | |
line_item.invoice = Invoice.create! | |
assert_queries(3) { line_item.touch } | |
end | |
def test_belongs_to_counter_after_update | |
topic = Topic.create!(title: "37s") | |
topic.replies.create!(title: "re: 37s", content: "rails") | |
assert_equal 1, Topic.find(topic.id)[:replies_count] | |
topic.update(title: "37signals") | |
assert_equal 1, Topic.find(topic.id)[:replies_count] | |
end | |
def test_belongs_to_counter_when_update_columns | |
topic = Topic.create!(title: "37s") | |
topic.replies.create!(title: "re: 37s", content: "rails") | |
assert_equal 1, Topic.find(topic.id)[:replies_count] | |
topic.update_columns(content: "rails is wonderful") | |
assert_equal 1, Topic.find(topic.id)[:replies_count] | |
end | |
def test_assignment_before_child_saved | |
final_cut = Client.new("name" => "Final Cut") | |
firm = Firm.find(1) | |
final_cut.firm = firm | |
assert_not_predicate final_cut, :persisted? | |
assert final_cut.save | |
assert_predicate final_cut, :persisted? | |
assert_predicate firm, :persisted? | |
assert_equal firm, final_cut.firm | |
final_cut.association(:firm).reload | |
assert_equal firm, final_cut.firm | |
end | |
def test_assignment_before_child_saved_with_primary_key | |
final_cut = Client.new("name" => "Final Cut") | |
firm = Firm.find(1) | |
final_cut.firm_with_primary_key = firm | |
assert_not_predicate final_cut, :persisted? | |
assert final_cut.save | |
assert_predicate final_cut, :persisted? | |
assert_predicate firm, :persisted? | |
assert_equal firm, final_cut.firm_with_primary_key | |
final_cut.association(:firm_with_primary_key).reload | |
assert_equal firm, final_cut.firm_with_primary_key | |
end | |
def test_new_record_with_foreign_key_but_no_object | |
client = Client.new("firm_id" => 1) | |
assert_equal Firm.first, client.firm_with_basic_id | |
end | |
def test_setting_foreign_key_after_nil_target_loaded | |
client = Client.new | |
client.firm_with_basic_id | |
client.firm_id = 1 | |
assert_equal companies(:first_firm), client.firm_with_basic_id | |
end | |
def test_polymorphic_setting_foreign_key_after_nil_target_loaded | |
sponsor = Sponsor.new | |
sponsor.sponsorable | |
sponsor.sponsorable_id = 1 | |
sponsor.sponsorable_type = "Member" | |
assert_equal members(:groucho), sponsor.sponsorable | |
end | |
def test_dont_find_target_when_foreign_key_is_null | |
tagging = taggings(:thinking_general) | |
assert_no_queries { tagging.super_tag } | |
end | |
def test_dont_find_target_when_saving_foreign_key_after_stale_association_loaded | |
client = Client.create!(name: "Test client", firm_with_basic_id: Firm.find(1)) | |
client.firm_id = Firm.create!(name: "Test firm").id | |
assert_queries(1) { client.save! } | |
end | |
def test_field_name_same_as_foreign_key | |
computer = Computer.find(1) | |
assert_not_nil computer.developer, ":foreign key == attribute didn't lock up" # ' | |
end | |
def test_counter_cache | |
topic = Topic.create title: "Zoom-zoom-zoom" | |
assert_equal 0, topic[:replies_count] | |
reply = Reply.create(title: "re: zoom", content: "speedy quick!") | |
reply.topic = topic | |
reply.save! | |
assert_equal 1, topic.reload[:replies_count] | |
assert_equal 1, topic.replies.size | |
topic[:replies_count] = 15 | |
assert_equal 15, topic.replies.size | |
end | |
def test_counter_cache_double_destroy | |
topic = Topic.create title: "Zoom-zoom-zoom" | |
5.times do | |
topic.replies.create(title: "re: zoom", content: "speedy quick!") | |
end | |
assert_equal 5, topic.reload[:replies_count] | |
assert_equal 5, topic.replies.size | |
reply = topic.replies.first | |
reply.destroy | |
assert_equal 4, topic.reload[:replies_count] | |
reply.destroy | |
assert_equal 4, topic.reload[:replies_count] | |
assert_equal 4, topic.replies.size | |
end | |
def test_concurrent_counter_cache_double_destroy | |
topic = Topic.create title: "Zoom-zoom-zoom" | |
5.times do | |
topic.replies.create(title: "re: zoom", content: "speedy quick!") | |
end | |
assert_equal 5, topic.reload[:replies_count] | |
assert_equal 5, topic.replies.size | |
reply = topic.replies.first | |
reply_clone = Reply.find(reply.id) | |
reply.destroy | |
assert_equal 4, topic.reload[:replies_count] | |
reply_clone.destroy | |
assert_equal 4, topic.reload[:replies_count] | |
assert_equal 4, topic.replies.size | |
end | |
def test_custom_counter_cache | |
reply = Reply.create(title: "re: zoom", content: "speedy quick!") | |
assert_equal 0, reply[:replies_count] | |
silly = SillyReply.create(title: "gaga", content: "boo-boo") | |
silly.reply = reply | |
silly.save! | |
assert_equal 1, reply.reload[:replies_count] | |
assert_equal 1, reply.replies.size | |
reply[:replies_count] = 17 | |
assert_equal 17, reply.replies.size | |
end | |
def test_replace_counter_cache | |
topic = Topic.create(title: "Zoom-zoom-zoom") | |
reply = Reply.create(title: "re: zoom", content: "speedy quick!") | |
reply.topic = topic | |
reply.save | |
topic.reload | |
assert_equal 1, topic.replies_count | |
end | |
def test_association_assignment_sticks | |
post = Post.first | |
author1, author2 = Author.all.merge!(limit: 2).to_a | |
assert_not_nil author1 | |
assert_not_nil author2 | |
# make sure the association is loaded | |
post.author | |
# set the association by id, directly | |
post.author_id = author2.id | |
# save and reload | |
post.save! | |
post.reload | |
# the author id of the post should be the id we set | |
assert_equal post.author_id, author2.id | |
end | |
def test_cant_save_readonly_association | |
assert_raise(ActiveRecord::ReadOnlyRecord) { companies(:first_client).readonly_firm.save! } | |
assert_predicate companies(:first_client).readonly_firm, :readonly? | |
end | |
def test_polymorphic_assignment_foreign_key_type_string | |
comment = Comment.first | |
comment.author = authors(:david) | |
comment.resource = members(:groucho) | |
comment.save | |
assert_equal 1, authors(:david).id | |
assert_equal 1, comment.author_id | |
assert_equal authors(:david), Comment.includes(:author).first.author | |
assert_equal 1, members(:groucho).id | |
assert_equal "1", comment.resource_id | |
assert_equal members(:groucho), Comment.includes(:resource).first.resource | |
end | |
def test_polymorphic_assignment_foreign_type_field_updating | |
# should update when assigning a saved record | |
sponsor = Sponsor.new | |
member = Member.create | |
sponsor.sponsorable = member | |
assert_equal "Member", sponsor.sponsorable_type | |
# should update when assigning a new record | |
sponsor = Sponsor.new | |
member = Member.new | |
sponsor.sponsorable = member | |
assert_equal "Member", sponsor.sponsorable_type | |
end | |
def test_polymorphic_assignment_with_primary_key_foreign_type_field_updating | |
# should update when assigning a saved record | |
essay = Essay.new | |
writer = Author.create(name: "David") | |
essay.writer = writer | |
assert_equal "Author", essay.writer_type | |
# should update when assigning a new record | |
essay = Essay.new | |
writer = Author.new | |
essay.writer = writer | |
assert_equal "Author", essay.writer_type | |
end | |
def test_polymorphic_assignment_updates_foreign_id_field_for_new_and_saved_records | |
sponsor = Sponsor.new | |
saved_member = Member.create | |
new_member = Member.new | |
sponsor.sponsorable = saved_member | |
assert_equal saved_member.id, sponsor.sponsorable_id | |
sponsor.sponsorable = new_member | |
assert_nil sponsor.sponsorable_id | |
end | |
def test_assignment_updates_foreign_id_field_for_new_and_saved_records | |
client = Client.new | |
saved_firm = Firm.create name: "Saved" | |
new_firm = Firm.new | |
client.firm = saved_firm | |
assert_equal saved_firm.id, client.client_of | |
client.firm = new_firm | |
assert_nil client.client_of | |
end | |
def test_polymorphic_assignment_with_primary_key_updates_foreign_id_field_for_new_and_saved_records | |
essay = Essay.new | |
saved_writer = Author.create(name: "David") | |
new_writer = Author.new | |
essay.writer = saved_writer | |
assert_equal saved_writer.name, essay.writer_id | |
essay.writer = new_writer | |
assert_nil essay.writer_id | |
end | |
def test_polymorphic_assignment_with_nil | |
essay = Essay.new | |
assert_nil essay.writer_id | |
assert_nil essay.writer_type | |
essay.writer_id = 1 | |
essay.writer_type = "Author" | |
essay.writer = nil | |
assert_nil essay.writer_id | |
assert_nil essay.writer_type | |
end | |
def test_belongs_to_proxy_should_not_respond_to_private_methods | |
assert_raise(NoMethodError) { companies(:first_firm).private_method } | |
assert_raise(NoMethodError) { companies(:second_client).firm.private_method } | |
end | |
def test_belongs_to_proxy_should_respond_to_private_methods_via_send | |
companies(:first_firm).send(:private_method) | |
companies(:second_client).firm.send(:private_method) | |
end | |
def test_save_of_record_with_loaded_belongs_to | |
@account = companies(:first_firm).account | |
assert_nothing_raised do | |
Account.find(@account.id).save! | |
Account.all.merge!(includes: :firm).find(@account.id).save! | |
end | |
@account.firm.delete | |
assert_nothing_raised do | |
Account.find(@account.id).save! | |
Account.all.merge!(includes: :firm).find(@account.id).save! | |
end | |
end | |
def test_dependent_delete_and_destroy_with_belongs_to | |
AuthorAddress.destroyed_author_address_ids.clear | |
author_address = author_addresses(:david_address) | |
author_address_extra = author_addresses(:david_address_extra) | |
assert_equal [], AuthorAddress.destroyed_author_address_ids | |
assert_difference "AuthorAddress.count", -2 do | |
authors(:david).destroy | |
end | |
assert_equal [], AuthorAddress.where(id: [author_address.id, author_address_extra.id]) | |
assert_equal [author_address.id], AuthorAddress.destroyed_author_address_ids | |
end | |
def test_belongs_to_invalid_dependent_option_raises_exception | |
error = assert_raise ArgumentError do | |
Class.new(Author).belongs_to :special_author_address, dependent: :nullify | |
end | |
assert_equal error.message, "The :dependent option must be one of [:destroy, :delete, :destroy_async], but is :nullify" | |
end | |
class EssayDestroy < ActiveRecord::Base | |
self.table_name = "essays" | |
belongs_to :book, dependent: :destroy, class_name: "DestroyableBook" | |
end | |
class DestroyableBook < ActiveRecord::Base | |
self.table_name = "books" | |
belongs_to :author, class_name: "UndestroyableAuthor", dependent: :destroy | |
end | |
class UndestroyableAuthor < ActiveRecord::Base | |
self.table_name = "authors" | |
has_one :book, class_name: "DestroyableBook", foreign_key: "author_id" | |
before_destroy :dont | |
def dont | |
throw(:abort) | |
end | |
end | |
def test_dependency_should_halt_parent_destruction | |
author = UndestroyableAuthor.create!(name: "Test") | |
book = DestroyableBook.create!(author: author) | |
assert_no_difference ["UndestroyableAuthor.count", "DestroyableBook.count"] do | |
assert_not book.destroy | |
end | |
end | |
def test_dependency_should_halt_parent_destruction_with_cascaded_three_levels | |
author = UndestroyableAuthor.create!(name: "Test") | |
book = DestroyableBook.create!(author: author) | |
essay = EssayDestroy.create!(book: book) | |
assert_no_difference ["UndestroyableAuthor.count", "DestroyableBook.count", "EssayDestroy.count"] do | |
assert_not essay.destroy | |
assert_not essay.destroyed? | |
end | |
end | |
def test_attributes_are_being_set_when_initialized_from_belongs_to_association_with_where_clause | |
new_firm = accounts(:signals37).build_firm(name: "Apple") | |
assert_equal new_firm.name, "Apple" | |
end | |
def test_attributes_are_set_without_error_when_initialized_from_belongs_to_association_with_array_in_where_clause | |
new_account = Account.where(credit_limit: [ 50, 60 ]).new | |
assert_nil new_account.credit_limit | |
end | |
def test_reassigning_the_parent_id_updates_the_object | |
client = companies(:second_client) | |
client.firm | |
client.firm_with_condition | |
firm_proxy = client.send(:association_instance_get, :firm) | |
firm_with_condition_proxy = client.send(:association_instance_get, :firm_with_condition) | |
assert_not_predicate firm_proxy, :stale_target? | |
assert_not_predicate firm_with_condition_proxy, :stale_target? | |
assert_equal companies(:first_firm), client.firm | |
assert_equal companies(:first_firm), client.firm_with_condition | |
client.client_of = companies(:another_firm).id | |
assert_predicate firm_proxy, :stale_target? | |
assert_predicate firm_with_condition_proxy, :stale_target? | |
assert_equal companies(:another_firm), client.firm | |
assert_equal companies(:another_firm), client.firm_with_condition | |
end | |
def test_assigning_nil_on_an_association_clears_the_associations_inverse | |
with_has_many_inversing do | |
book = Book.create! | |
citation = book.citations.create! | |
assert_same book, citation.book | |
assert_nothing_raised do | |
citation.book = nil | |
citation.save! | |
end | |
end | |
end | |
def test_clearing_an_association_clears_the_associations_inverse | |
author = Author.create(name: "Jimmy Tolkien") | |
post = author.create_post(title: "The silly medallion", body: "") | |
assert_equal post, author.post | |
assert_equal author, post.author | |
author.update!(post: nil) | |
assert_nil author.post | |
post.update!(title: "The Silmarillion") | |
assert_nil author.post | |
end | |
def test_destroying_child_with_unloaded_parent_and_foreign_key_and_touch_is_possible_with_has_many_inversing | |
with_has_many_inversing do | |
book = Book.create! | |
citation = book.citations.create! | |
assert_difference "Citation.count", -1 do | |
Citation.find(citation.id).destroy | |
end | |
end | |
end | |
def test_polymorphic_reassignment_of_associated_id_updates_the_object | |
sponsor = sponsors(:moustache_club_sponsor_for_groucho) | |
sponsor.sponsorable | |
proxy = sponsor.send(:association_instance_get, :sponsorable) | |
assert_not_predicate proxy, :stale_target? | |
assert_equal members(:groucho), sponsor.sponsorable | |
sponsor.sponsorable_id = members(:some_other_guy).id | |
assert_predicate proxy, :stale_target? | |
assert_equal members(:some_other_guy), sponsor.sponsorable | |
end | |
def test_polymorphic_reassignment_of_associated_type_updates_the_object | |
sponsor = sponsors(:moustache_club_sponsor_for_groucho) | |
sponsor.sponsorable | |
proxy = sponsor.send(:association_instance_get, :sponsorable) | |
assert_not_predicate proxy, :stale_target? | |
assert_equal members(:groucho), sponsor.sponsorable | |
sponsor.sponsorable_type = "Firm" | |
assert_predicate proxy, :stale_target? | |
assert_equal companies(:first_firm), sponsor.sponsorable | |
end | |
def test_reloading_association_with_key_change | |
client = companies(:second_client) | |
firm = client.association(:firm) | |
client.firm = companies(:another_firm) | |
firm.reload | |
assert_equal companies(:another_firm), firm.target | |
client.client_of = companies(:first_firm).id | |
firm.reload | |
assert_equal companies(:first_firm), firm.target | |
end | |
def test_polymorphic_counter_cache | |
tagging = taggings(:welcome_general) | |
post = posts(:welcome) | |
comment = comments(:greetings) | |
assert_equal post.id, comment.id | |
assert_difference "post.reload.tags_count", -1 do | |
assert_difference "comment.reload.tags_count", +1 do | |
tagging.taggable = comment | |
tagging.save! | |
end | |
end | |
assert_difference "comment.reload.tags_count", -1 do | |
assert_difference "post.reload.tags_count", +1 do | |
tagging.taggable_type = post.class.polymorphic_name | |
tagging.taggable_id = post.id | |
tagging.save! | |
end | |
end | |
end | |
def test_polymorphic_with_custom_foreign_type | |
sponsor = sponsors(:moustache_club_sponsor_for_groucho) | |
groucho = members(:groucho) | |
other = members(:some_other_guy) | |
assert_equal groucho, sponsor.sponsorable | |
assert_equal groucho, sponsor.thing | |
sponsor.thing = other | |
assert_equal other, sponsor.sponsorable | |
assert_equal other, sponsor.thing | |
sponsor.sponsorable = groucho | |
assert_equal groucho, sponsor.sponsorable | |
assert_equal groucho, sponsor.thing | |
end | |
class WheelPolymorphicName < ActiveRecord::Base | |
self.table_name = "wheels" | |
belongs_to :wheelable, polymorphic: true, counter_cache: :wheels_count, touch: :wheels_owned_at | |
def self.polymorphic_class_for(name) | |
raise "Unexpected name: #{name}" unless name == "polymorphic_car" | |
CarPolymorphicName | |
end | |
end | |
class CarPolymorphicName < ActiveRecord::Base | |
self.table_name = "cars" | |
has_many :wheels, as: :wheelable | |
def self.polymorphic_name | |
"polymorphic_car" | |
end | |
end | |
def test_polymorphic_with_custom_name_counter_cache | |
car = CarPolymorphicName.create! | |
wheel = WheelPolymorphicName.create!(wheelable_type: "polymorphic_car", wheelable_id: car.id) | |
assert_equal 1, car.reload.wheels_count | |
wheel.update! wheelable: nil | |
assert_equal 0, car.reload.wheels_count | |
end | |
def test_polymorphic_with_custom_name_touch_old_belongs_to_model | |
car = CarPolymorphicName.create! | |
wheel = WheelPolymorphicName.create!(wheelable: car) | |
touch_time = 1.day.ago.round | |
travel_to(touch_time) do | |
wheel.update!(wheelable: nil) | |
end | |
assert_equal touch_time, car.reload.wheels_owned_at | |
end | |
def test_build_with_conditions | |
client = companies(:second_client) | |
firm = client.build_bob_firm | |
assert_equal "Bob", firm.name | |
end | |
def test_create_with_conditions | |
client = companies(:second_client) | |
firm = client.create_bob_firm | |
assert_equal "Bob", firm.name | |
end | |
def test_create_bang_with_conditions | |
client = companies(:second_client) | |
firm = client.create_bob_firm! | |
assert_equal "Bob", firm.name | |
end | |
def test_build_with_block | |
client = Client.create(name: "Client Company") | |
firm = client.build_firm { |f| f.name = "Agency Company" } | |
assert_equal "Agency Company", firm.name | |
end | |
def test_create_with_block | |
client = Client.create(name: "Client Company") | |
firm = client.create_firm { |f| f.name = "Agency Company" } | |
assert_equal "Agency Company", firm.name | |
end | |
def test_create_bang_with_block | |
client = Client.create(name: "Client Company") | |
firm = client.create_firm! { |f| f.name = "Agency Company" } | |
assert_equal "Agency Company", firm.name | |
end | |
def test_should_set_foreign_key_on_create_association | |
client = Client.create! name: "fuu" | |
firm = client.create_firm name: "baa" | |
assert_equal firm.id, client.client_of | |
end | |
def test_should_set_foreign_key_on_create_association! | |
client = Client.create! name: "fuu" | |
firm = client.create_firm! name: "baa" | |
assert_equal firm.id, client.client_of | |
end | |
def test_self_referential_belongs_to_with_counter_cache_assigning_nil | |
comment = Comment.create! post: posts(:thinking), body: "fuu" | |
comment.parent = nil | |
comment.save! | |
assert_nil comment.reload.parent | |
assert_equal 0, comments(:greetings).reload.children_count | |
end | |
def test_belongs_to_with_id_assigning | |
post = posts(:welcome) | |
comment = Comment.create! body: "foo", post: post | |
parent = comments(:greetings) | |
assert_equal 0, parent.reload.children_count | |
comment.parent_id = parent.id | |
comment.save! | |
assert_equal 1, parent.reload.children_count | |
end | |
def test_belongs_to_with_out_of_range_value_assigning | |
model = Class.new(Author) do | |
def self.name; "Temp"; end | |
validates :author_address, presence: true | |
end | |
author = model.new | |
author.author_address_id = 9223372036854775808 # out of range in the bigint | |
assert_nil author.author_address | |
assert_not_predicate author, :valid? | |
assert_equal [{ error: :blank }], author.errors.details[:author_address] | |
end | |
def test_polymorphic_with_custom_primary_key | |
toy = Toy.create! | |
sponsor = Sponsor.create!(sponsorable: toy) | |
assert_equal toy, sponsor.reload.sponsorable | |
end | |
class SponsorWithTouchInverse < Sponsor | |
belongs_to :sponsorable, polymorphic: true, inverse_of: :sponsors, touch: true | |
end | |
def test_destroying_polymorphic_child_with_unloaded_parent_and_touch_is_possible_with_has_many_inversing | |
with_has_many_inversing do | |
toy = Toy.create! | |
sponsor = toy.sponsors.create! | |
assert_difference "Sponsor.count", -1 do | |
SponsorWithTouchInverse.find(sponsor.id).destroy | |
end | |
end | |
end | |
def test_polymorphic_with_false | |
assert_nothing_raised do | |
Class.new(ActiveRecord::Base) do | |
def self.name; "Post"; end | |
belongs_to :category, polymorphic: false | |
end | |
end | |
end | |
test "stale tracking doesn't care about the type" do | |
apple = Firm.create("name" => "Apple") | |
citibank = Account.create("credit_limit" => 10) | |
citibank.firm_id = apple.id | |
citibank.firm # load it | |
citibank.firm_id = apple.id.to_s | |
assert_not_predicate citibank.association(:firm), :stale_target? | |
end | |
def test_reflect_the_most_recent_change | |
author1, author2 = Author.limit(2) | |
post = Post.new(title: "foo", body: "bar") | |
post.author = author1 | |
post.author_id = author2.id | |
assert post.save | |
assert_equal post.author_id, author2.id | |
end | |
test "dangerous association name raises ArgumentError" do | |
[:errors, "errors", :save, "save"].each do |name| | |
assert_raises(ArgumentError, "Association #{name} should not be allowed") do | |
Class.new(ActiveRecord::Base) do | |
belongs_to name | |
end | |
end | |
end | |
end | |
test "belongs_to works with model called Record" do | |
record = Record.create! | |
Column.create! record: record | |
assert_equal 1, Column.count | |
end | |
def test_multiple_counter_cache_with_after_create_update | |
post = posts(:welcome) | |
parent = comments(:greetings) | |
assert_difference "parent.reload.children_count", +1 do | |
assert_difference "post.reload.comments_count", +1 do | |
CommentWithAfterCreateUpdate.create(body: "foo", post: post, parent: parent) | |
end | |
end | |
end | |
test "assigning an association doesn't result in duplicate objects" do | |
post = Post.create!(title: "title", body: "body") | |
post.comments = [post.comments.build(body: "body")] | |
post.save! | |
assert_equal 1, post.comments.size | |
assert_equal 1, Comment.where(post_id: post.id).count | |
assert_equal post.id, Comment.last.post.id | |
end | |
test "tracking change from one persisted record to another" do | |
node = nodes(:child_one_of_a) | |
assert_not_nil node.parent | |
assert_not node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.parent = nodes(:grandparent) | |
assert node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.save! | |
assert_not node.parent_changed? | |
assert node.parent_previously_changed? | |
end | |
test "tracking change from persisted record to new record" do | |
node = nodes(:child_one_of_a) | |
assert_not_nil node.parent | |
assert_not node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.parent = Node.new(tree: node.tree, parent: nodes(:parent_a), name: "Child three") | |
assert node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.save! | |
assert_not node.parent_changed? | |
assert node.parent_previously_changed? | |
end | |
test "tracking change from persisted record to nil" do | |
node = nodes(:child_one_of_a) | |
assert_not_nil node.parent | |
assert_not node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.parent = nil | |
assert node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.save! | |
assert_not node.parent_changed? | |
assert node.parent_previously_changed? | |
end | |
test "tracking change from nil to persisted record" do | |
node = nodes(:grandparent) | |
assert_nil node.parent | |
assert_not node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.parent = Node.create!(tree: node.tree, name: "Great-grandparent") | |
assert node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.save! | |
assert_not node.parent_changed? | |
assert node.parent_previously_changed? | |
end | |
test "tracking change from nil to new record" do | |
node = nodes(:grandparent) | |
assert_nil node.parent | |
assert_not node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.parent = Node.new(tree: node.tree, name: "Great-grandparent") | |
assert node.parent_changed? | |
assert_not node.parent_previously_changed? | |
node.save! | |
assert_not node.parent_changed? | |
assert node.parent_previously_changed? | |
end | |
test "tracking polymorphic changes" do | |
comment = comments(:greetings) | |
assert_nil comment.author | |
assert_not comment.author_changed? | |
assert_not comment.author_previously_changed? | |
comment.author = authors(:david) | |
assert comment.author_changed? | |
comment.save! | |
assert_not comment.author_changed? | |
assert comment.author_previously_changed? | |
assert_equal authors(:david).id, companies(:first_firm).id | |
comment.author = companies(:first_firm) | |
assert comment.author_changed? | |
comment.save! | |
assert_not comment.author_changed? | |
assert comment.author_previously_changed? | |
end | |
end | |
class BelongsToWithForeignKeyTest < ActiveRecord::TestCase | |
fixtures :authors, :author_addresses | |
def test_destroy_linked_models | |
address = AuthorAddress.create! | |
author = Author.create! name: "Author", author_address_id: address.id | |
author.destroy! | |
end | |
end |
# frozen_string_literal: true | |
module ActiveRecord | |
module Associations | |
# = Active Record Belongs To Polymorphic Association | |
class BelongsToPolymorphicAssociation < BelongsToAssociation # :nodoc: | |
def klass | |
type = owner[reflection.foreign_type] | |
type.presence && owner.class.polymorphic_class_for(type) | |
end | |
def target_changed? | |
super || owner.attribute_changed?(reflection.foreign_type) | |
end | |
def target_previously_changed? | |
super || owner.attribute_previously_changed?(reflection.foreign_type) | |
end | |
def saved_change_to_target? | |
super || owner.saved_change_to_attribute?(reflection.foreign_type) | |
end | |
private | |
def replace_keys(record, force: false) | |
super | |
target_type = record ? record.class.polymorphic_name : nil | |
if force || owner._read_attribute(reflection.foreign_type) != target_type | |
owner[reflection.foreign_type] = target_type | |
end | |
end | |
def inverse_reflection_for(record) | |
reflection.polymorphic_inverse_of(record.class) | |
end | |
def raise_on_type_mismatch!(record) | |
# A polymorphic association cannot have a type mismatch, by definition | |
end | |
def stale_state | |
foreign_key = super | |
foreign_key && [foreign_key.to_s, owner[reflection.foreign_type].to_s] | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "benchmark" | |
class << Benchmark | |
# Benchmark realtime in milliseconds. | |
# | |
# Benchmark.realtime { User.all } | |
# # => 8.0e-05 | |
# | |
# Benchmark.ms { User.all } | |
# # => 0.074 | |
def ms(&block) | |
1000 * realtime(&block) | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/benchmark" | |
require "active_support/core_ext/hash/keys" | |
module ActiveSupport | |
module Benchmarkable | |
# Allows you to measure the execution time of a block in a template and | |
# records the result to the log. Wrap this block around expensive operations | |
# or possible bottlenecks to get a time reading for the operation. For | |
# example, let's say you thought your file processing method was taking too | |
# long; you could wrap it in a benchmark block. | |
# | |
# <% benchmark 'Process data files' do %> | |
# <%= expensive_files_operation %> | |
# <% end %> | |
# | |
# That would add something like "Process data files (345.2ms)" to the log, | |
# which you can then use to compare timings when optimizing your code. | |
# | |
# You may give an optional logger level (<tt>:debug</tt>, <tt>:info</tt>, | |
# <tt>:warn</tt>, <tt>:error</tt>) as the <tt>:level</tt> option. The | |
# default logger level value is <tt>:info</tt>. | |
# | |
# <% benchmark 'Low-level files', level: :debug do %> | |
# <%= lowlevel_files_operation %> | |
# <% end %> | |
# | |
# Finally, you can pass true as the third argument to silence all log | |
# activity (other than the timing information) from inside the block. This | |
# is great for boiling down a noisy block to just a single statement that | |
# produces one log line: | |
# | |
# <% benchmark 'Process data files', level: :info, silence: true do %> | |
# <%= expensive_and_chatty_files_operation %> | |
# <% end %> | |
def benchmark(message = "Benchmarking", options = {}, &block) | |
if logger | |
options.assert_valid_keys(:level, :silence) | |
options[:level] ||= :info | |
result = nil | |
ms = Benchmark.ms { result = options[:silence] ? logger.silence(&block) : yield } | |
logger.public_send(options[:level], "%s (%.1fms)" % [ message, ms ]) | |
result | |
else | |
yield | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "abstract_unit" | |
class BenchmarkableTest < ActiveSupport::TestCase | |
include ActiveSupport::Benchmarkable | |
attr_reader :buffer, :logger | |
class Buffer | |
include Enumerable | |
def initialize; @lines = []; end | |
def each(&block); @lines.each(&block); end | |
def write(x); @lines << x; end | |
def close; end | |
def last; @lines.last; end | |
def size; @lines.size; end | |
def empty?; @lines.empty?; end | |
end | |
def setup | |
@buffer = Buffer.new | |
@logger = ActiveSupport::Logger.new(@buffer) | |
end | |
def test_without_block | |
assert_raise(LocalJumpError) { benchmark } | |
assert_empty buffer | |
end | |
def test_defaults | |
i_was_run = false | |
benchmark { i_was_run = true } | |
assert i_was_run | |
assert_last_logged | |
end | |
def test_with_message | |
i_was_run = false | |
benchmark("test_run") { i_was_run = true } | |
assert i_was_run | |
assert_last_logged "test_run" | |
end | |
def test_with_silence | |
assert_difference "buffer.count", +2 do | |
benchmark("test_run") do | |
logger.info "SOMETHING" | |
end | |
end | |
assert_difference "buffer.count", +1 do | |
benchmark("test_run", silence: true) do | |
logger.info "NOTHING" | |
end | |
end | |
end | |
def test_within_level | |
logger.level = ActiveSupport::Logger::DEBUG | |
benchmark("included_debug_run", level: :debug) { } | |
assert_last_logged "included_debug_run" | |
end | |
def test_outside_level | |
logger.level = ActiveSupport::Logger::ERROR | |
benchmark("skipped_debug_run", level: :debug) { } | |
assert_no_match(/skipped_debug_run/, buffer.last) | |
ensure | |
logger.level = ActiveSupport::Logger::DEBUG | |
end | |
private | |
def assert_last_logged(message = "Benchmarking") | |
assert_match(/^#{message} \(.*\)$/, buffer.last) | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/content" | |
class BidirectionalDestroyDependenciesTest < ActiveRecord::TestCase | |
fixtures :content, :content_positions | |
def setup | |
Content.destroyed_ids.clear | |
ContentPosition.destroyed_ids.clear | |
end | |
def test_bidirectional_dependence_when_destroying_item_with_belongs_to_association | |
content_position = ContentPosition.find(1) | |
content = content_position.content | |
assert_not_nil content | |
content_position.destroy | |
assert_equal [content_position.id], ContentPosition.destroyed_ids | |
assert_equal [content.id], Content.destroyed_ids | |
end | |
def test_bidirectional_dependence_when_destroying_item_with_has_one_association | |
content = Content.find(1) | |
content_position = content.content_position | |
assert_not_nil content_position | |
content.destroy | |
assert_equal [content.id], Content.destroyed_ids | |
assert_equal [content_position.id], ContentPosition.destroyed_ids | |
end | |
def test_bidirectional_dependence_when_destroying_item_with_has_one_association_fails_first_time | |
content = ContentWhichRequiresTwoDestroyCalls.find(1) | |
2.times { content.destroy } | |
assert_equal content.destroyed?, true | |
end | |
end |
# frozen_string_literal: true | |
require "active_support/core_ext/big_decimal/conversions" |
# frozen_string_literal: true | |
require "active_model/type/integer" | |
module ActiveModel | |
module Type | |
# Attribute type for integers that can be serialized to an unlimited number | |
# of bytes. This type is registered under the +:big_integer+ key. | |
# | |
# class Person | |
# include ActiveModel::Attributes | |
# | |
# attribute :id, :big_integer | |
# end | |
# | |
# person = Person.new | |
# person.id = "18_000_000_000" | |
# | |
# person.id # => 18000000000 | |
# | |
# All casting and serialization are performed in the same way as the | |
# standard ActiveModel::Type::Integer type. | |
class BigInteger < Integer | |
private | |
def max_value | |
::Float::INFINITY | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
module ActiveModel | |
module Type | |
class BigIntegerTest < ActiveModel::TestCase | |
def test_type_cast_big_integer | |
type = Type::BigInteger.new | |
assert_equal 1, type.cast(1) | |
assert_equal 1, type.cast("1") | |
end | |
def test_small_values | |
type = Type::BigInteger.new | |
assert_equal(-9999999999999999999999999999999, type.serialize(-9999999999999999999999999999999)) | |
end | |
def test_large_values | |
type = Type::BigInteger.new | |
assert_equal 9999999999999999999999999999999, type.serialize(9999999999999999999999999999999) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../abstract_unit" | |
require "active_support/core_ext/big_decimal" | |
class BigDecimalTest < ActiveSupport::TestCase | |
def test_to_s | |
bd = BigDecimal "0.01" | |
assert_equal "0.01", bd.to_s | |
assert_equal "+0.01", bd.to_s("+F") | |
assert_equal "+0.0 1", bd.to_s("+1F") | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../helper" | |
module Arel | |
module Nodes | |
class TestBin < Arel::Test | |
def test_new | |
assert Arel::Nodes::Bin.new("zomg") | |
end | |
def test_default_to_sql | |
viz = Arel::Visitors::ToSql.new Table.engine.connection_pool | |
node = Arel::Nodes::Bin.new(Arel.sql("zomg")) | |
assert_equal "zomg", viz.accept(node, Collectors::SQLString.new).value | |
end | |
def test_mysql_to_sql | |
viz = Arel::Visitors::MySQL.new Table.engine.connection_pool | |
node = Arel::Nodes::Bin.new(Arel.sql("zomg")) | |
assert_equal "BINARY zomg", viz.accept(node, Collectors::SQLString.new).value | |
end | |
def test_equality_with_same_ivars | |
array = [Bin.new("zomg"), Bin.new("zomg")] | |
assert_equal 1, array.uniq.size | |
end | |
def test_inequality_with_different_ivars | |
array = [Bin.new("zomg"), Bin.new("zomg!")] | |
assert_equal 2, array.uniq.size | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
class Binary < ActiveRecord::Base | |
end |
# frozen_string_literal: true | |
class BinaryField < ActiveRecord::Base | |
serialize :normal_blob | |
serialize :normal_text | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/binary" | |
class BinaryTest < ActiveRecord::TestCase | |
FIXTURES = %w(flowers.jpg example.log test.txt) | |
def test_mixed_encoding | |
str = +"\x80" | |
str.force_encoding("ASCII-8BIT") | |
binary = Binary.new name: "いただきます!", data: str | |
binary.save! | |
binary.reload | |
assert_equal str, binary.data | |
name = binary.name | |
assert_equal "いただきます!", name | |
end | |
def test_load_save | |
Binary.delete_all | |
FIXTURES.each do |filename| | |
data = File.read(ASSETS_ROOT + "/#{filename}") | |
data.force_encoding("ASCII-8BIT") | |
data.freeze | |
bin = Binary.new(data: data) | |
assert_equal data, bin.data, "Newly assigned data differs from original" | |
bin.save! | |
assert_equal data, bin.data, "Data differs from original after save" | |
assert_equal data, bin.reload.data, "Reloaded data differs from original" | |
end | |
end | |
end |
# frozen_string_literal: true | |
module Arel # :nodoc: all | |
module Collectors | |
class Bind | |
def initialize | |
@binds = [] | |
end | |
def <<(str) | |
self | |
end | |
def add_bind(bind) | |
@binds << bind | |
self | |
end | |
def add_binds(binds, proc_for_binds = nil) | |
@binds.concat proc_for_binds ? binds.map(&proc_for_binds) : binds | |
self | |
end | |
def value | |
@binds | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
module Arel # :nodoc: all | |
module Nodes | |
class BindParam < Node | |
attr_reader :value | |
def initialize(value) | |
@value = value | |
super() | |
end | |
def hash | |
[self.class, self.value].hash | |
end | |
def eql?(other) | |
other.is_a?(BindParam) && | |
value == other.value | |
end | |
alias :== :eql? | |
def nil? | |
value.nil? | |
end | |
def value_before_type_cast | |
if value.respond_to?(:value_before_type_cast) | |
value.value_before_type_cast | |
else | |
value | |
end | |
end | |
def infinite? | |
value.respond_to?(:infinite?) && value.infinite? | |
end | |
def unboundable? | |
value.respond_to?(:unboundable?) && value.unboundable? | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require_relative "../helper" | |
module Arel | |
module Nodes | |
describe "BindParam" do | |
it "is equal to other bind params with the same value" do | |
_(BindParam.new(1)).must_equal(BindParam.new(1)) | |
_(BindParam.new("foo")).must_equal(BindParam.new("foo")) | |
end | |
it "is not equal to other nodes" do | |
_(BindParam.new(nil)).wont_equal(Node.new) | |
end | |
it "is not equal to bind params with different values" do | |
_(BindParam.new(1)).wont_equal(BindParam.new(2)) | |
end | |
end | |
end | |
end |
# frozen_string_literal: true | |
require "cases/helper" | |
require "models/topic" | |
require "models/reply" | |
require "models/author" | |
require "models/post" | |
if ActiveRecord::Base.connection.prepared_statements | |
module ActiveRecord | |
class BindParameterTest < ActiveRecord::TestCase | |
fixtures :topics, :authors, :author_addresses, :posts | |
class LogListener | |
attr_accessor :calls | |
def initialize | |
@calls = [] | |
end | |
def call(*args) | |
calls << args | |
end | |
end | |
def setup | |
super | |
@connection = ActiveRecord::Base.connection | |
@subscriber = LogListener.new | |
@subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber) | |
end | |
def teardown | |
ActiveSupport::Notifications.unsubscribe(@subscription) | |
end | |
def test_statement_cache | |
@connection.clear_cache! | |
topics = Topic.where(id: 1) | |
assert_equal [1], topics.map(&:id) | |
assert_includes statement_cache, to_sql_key(topics.arel) | |
@connection.clear_cache! | |
assert_not_includes statement_cache, to_sql_key(topics.arel) | |
end | |
def test_statement_cache_with_query_cache | |
@connection.enable_query_cache! | |
@connection.clear_cache! | |
topics = Topic.where(id: 1) | |
assert_equal [1], topics.map(&:id) | |
assert_includes statement_cache, to_sql_key(topics.arel) | |
ensure | |
@connection.disable_query_cache! | |
end | |
def test_statement_cache_with_find | |
@connection.clear_cache! | |
assert_equal 1, Topic.find(1).id | |
assert_raises(RecordNotFound) { SillyReply.find(2) } | |
topic_sql = cached_statement(Topic, [Topic.primary_key]) | |
assert_includes statement_cache, to_sql_key(topic_sql) | |
reply_sql = cached_statement(SillyReply, [SillyReply.primary_key]) | |
assert_includes statement_cache, to_sql_key(reply_sql) | |
replies = SillyReply.where(id: 2).limit(1) | |
assert_includes statement_cache, to_sql_key(replies.arel) | |
end | |
def test_statement_cache_with_find_by | |
@connection.clear_cache! | |
assert_equal 1, Topic.find_by!(id: 1).id | |
assert_raises(RecordNotFound) { SillyReply.find_by!(id: 2) } | |
topic_sql = cached_statement(Topic, ["id"]) | |
assert_includes statement_cache, to_sql_key(topic_sql) | |
reply_sql = cached_statement(SillyReply, ["id"]) | |
assert_includes statement_cache, to_sql_key(reply_sql) | |
replies = SillyReply.where(id: 2).limit(1) | |
assert_includes statement_cache, to_sql_key(replies.arel) | |
end | |
def test_statement_cache_with_in_clause | |
@connection.clear_cache! | |
topics = Topic.where(id: [1, 3]).order(:id) | |
assert_equal [1, 3], topics.map(&:id) | |
assert_not_includes statement_cache, to_sql_key(topics.arel) | |
end | |
def test_statement_cache_with_sql_string_literal | |
@connection.clear_cache! | |
topics = Topic.where("topics.id = ?", 1) | |
assert_equal [1], topics.map(&:id) | |
assert_not_includes statement_cache, to_sql_key(topics.arel) | |
end | |
def test_too_many_binds | |
bind_params_length = @connection.send(:bind_params_length) | |
topics = Topic.where(id: (1 .. bind_params_length).to_a << 2**63) | |
assert_equal Topic.count, topics.count | |
topics = Topic.where.not(id: (1 .. bind_params_length).to_a << 2**63) | |
assert_equal 0, topics.count | |
end | |
def test_too_many_binds_with_query_cache | |
@connection.enable_query_cache! | |
bind_params_length = @connection.send(:bind_params_length) | |
topics = Topic.where(id: (1 .. bind_params_length + 1).to_a) | |
assert_equal Topic.count, topics.count | |
topics = Topic.where.not(id: (1 .. bind_params_length + 1).to_a) | |
assert_equal 0, topics.count | |
ensure | |
@connection.disable_query_cache! | |
end | |
def test_bind_from_join_in_subquery | |
subquery = Author.joins(:thinking_posts).where(name: "David") | |
scope = Author.from(subquery, "authors").where(id: 1) | |
assert_equal 1, scope.count | |
end | |
def test_binds_are_logged | |
sub = Arel::Nodes::BindParam.new(1) | |
binds = [Relation::QueryAttribute.new("id", 1, Type::Value.new)] | |
sql = "select * from topics where id = #{sub.to_sql}" | |
@connection.exec_query(sql, "SQL", binds) | |
message = @subscriber.calls.find { |args| args[4][:sql] == sql } | |
assert_equal binds, message[4][:binds] | |
end | |
def test_find_one_uses_binds | |
Topic.find(1) | |
message = @subscriber.calls.find { |args| args[4][:binds].any? { |attr| attr.value == 1 } } | |
assert message, "expected a message with binds" | |
end | |
def test_logs_binds_after_type_cast | |
binds = [Relation::QueryAttribute.new("id", "10", Type::Integer.new)] | |
assert_logs_binds(binds) | |
end | |
def test_bind_params_to_sql_with_prepared_statements | |
assert_bind_params_to_sql | |
end | |
def test_bind_params_to_sql_with_unprepared_statements | |
@connection.unprepared_statement do | |
assert_bind_params_to_sql | |
end | |
end | |
def test_nested_unprepared_statements | |
assert_predicate @connection, :prepared_statements? | |
@connection.unprepared_statement do | |
assert_not_predicate @connection, :prepared_statements? | |
@connection.unprepared_statement do | |
assert_not_predicate @connection, :prepared_statements? | |
end | |
assert_not_predicate @connection, :prepared_statements? | |
end | |
assert_predicate @connection, :prepared_statements? | |
end | |
def test_binds_with_filtered_attributes | |
ActiveRecord::Base.filter_attributes = [:auth] | |
binds = [Relation::QueryAttribute.new("auth_token", "abcd", Type::String.new)] | |
assert_filtered_log_binds(binds) | |
ActiveRecord::Base.filter_attributes = [] | |
end | |
private | |
def assert_bind_params_to_sql | |
table = Author.quoted_table_name | |
pk = "#{table}.#{Author.quoted_primary_key}" | |
# prepared_statements: true | |
# | |
# SELECT `authors`.* FROM `authors` WHERE (`authors`.`id` IN (?, ?, ?) OR `authors`.`id` IS NULL) | |
# | |
# prepared_statements: false | |
# | |
# SELECT `authors`.* FROM `authors` WHERE (`authors`.`id` IN (1, 2, 3) OR `authors`.`id` IS NULL) | |
# | |
sql = "SELECT #{table}.* FROM #{table} WHERE (#{pk} IN (#{bind_params(1..3)}) OR #{pk} IS NULL)" | |
authors = Author.where(id: [1, 2, 3, nil]) | |
assert_equal sql, @connection.to_sql(authors.arel) | |
assert_sql(sql) { assert_equal 3, authors.length } | |
# prepared_statements: true | |
# | |
# SELECT `authors`.* FROM `authors` WHERE `authors`.`id` IN (?, ?, ?) | |
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)